diff --git a/CHANGELOG.md b/CHANGELOG.md index 700cd6d1..d4a79ea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,24 +15,36 @@ Fixed ## [0.3.25] - 2025-07-02 -### Fixed +### Added -- Fixed budget amount calculation for projects using monthly budget data entries -- Resolved perpetual drift issue where Terraform would detect changes in budget amounts -- Added proper handling for Kion API responses that omit the 'amount' field when using monthly data -- Added validation to ensure monthly budget data totals match the declared budget amount -- Fixed floating-point precision issues causing false drift detection (e.g., 5000.000000000001) -- Added detection for auto-generated monthly budget entries to prevent false drift +#### Resources -### Added +- `kion_billing_source_aws` - Manage AWS commercial billing sources with support for CUR, DBR, and FOCUS reports +- `kion_billing_source_aws_govcloud` - Manage AWS GovCloud billing sources with dedicated account type support +- `kion_billing_source_gcp` - Manage GCP billing sources with BigQuery export configuration +- `kion_billing_source_oci` - Manage OCI billing sources for commercial, government, and federal tenancies + +#### Data Sources + +- `kion_billing_sources` - Query multiple billing sources with filtering support + +#### Models + +- Billing source models for AWS, AWS GovCloud, GCP, and OCI with comprehensive field support + +#### Features -- Budget amount validation during plan phase using CustomizeDiff -- Helper functions for budget calculations: `IsAutoGeneratedBudgetData()`, `AlmostEqual()`, `roundToTwoDecimals()` -- Clear error messages when monthly budget totals don't match declared amounts +- Support for multiple cloud providers (AWS, GCP, OCI) with provider-specific configurations +- FOCUS report support for modern FinOps workflows +- Proprietary report support (CUR, DBR for AWS) +- Cross-account billing bucket access with IAM roles +- Account creation enablement for automated provisioning +- Validation for account numbers, date formats, and region specifications ### Changed -- Improved budget amount field description to clarify validation requirements +- Updated provider to register all new billing source resources and data sources +- Added support for v4 billing source API endpoints ## [0.3.24] - 2024-05-08 diff --git a/Makefile b/Makefile index b81d4114..3975a42c 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # This Makefile is an easy way to run common operations. -VERSION=0.3.25 +VERSION=0.3.26-dev TEST?=$$(go list ./... | grep -v 'vendor') HOSTNAME=github.com diff --git a/docs/data-sources/billing_sources.md b/docs/data-sources/billing_sources.md new file mode 100644 index 00000000..de0a6150 --- /dev/null +++ b/docs/data-sources/billing_sources.md @@ -0,0 +1,94 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kion_billing_sources Data Source - terraform-provider-kion" +subcategory: "" +description: |- + +--- + +# kion_billing_sources (Data Source) + + + +## Example Usage + +```terraform +# Example: Get all billing sources +data "kion_billing_sources" "all" { +} + +# Example: Filter billing sources by name +data "kion_billing_sources" "production" { + filter { + name = "name" + values = ["Production*"] + regex = true + } +} + +# Example: Filter billing sources by type +data "kion_billing_sources" "aws_sources" { + filter { + name = "type" + values = ["aws"] + } +} + +# Example: Filter billing sources that support account creation +data "kion_billing_sources" "account_creation_enabled" { + filter { + name = "account_creation" + values = ["true"] + } +} + +# Output examples +output "all_billing_sources" { + value = data.kion_billing_sources.all.list +} + +output "production_billing_sources" { + value = data.kion_billing_sources.production.list +} + +output "aws_billing_source_names" { + value = [for source in data.kion_billing_sources.aws_sources.list : source.name] +} +``` + + +## Schema + +### Optional + +- `filter` (Block List) (see [below for nested schema](#nestedblock--filter)) + +### Read-Only + +- `id` (String) The ID of this resource. +- `list` (List of Object) This is where Kion makes the discovered data available as a list of resources. (see [below for nested schema](#nestedatt--list)) + + +### Nested Schema for `filter` + +Required: + +- `name` (String) The field name whose values you wish to filter by. +- `values` (List of String) The values of the field name you specified. + +Optional: + +- `regex` (Boolean) Dictates if the values provided should be treated as regular expressions. + + + +### Nested Schema for `list` + +Read-Only: + +- `account_creation` (Boolean) +- `id` (Number) +- `name` (String) +- `type` (String) +- `use_focus_reports` (Boolean) +- `use_proprietary_reports` (Boolean) diff --git a/docs/resources/billing_source_aws.md b/docs/resources/billing_source_aws.md new file mode 100644 index 00000000..cd34cfa4 --- /dev/null +++ b/docs/resources/billing_source_aws.md @@ -0,0 +1,127 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kion_billing_source_aws Resource - terraform-provider-kion" +subcategory: "" +description: |- + Creates and manages an AWS commercial billing source. + AWS billing sources enable cost management and account management capabilities by connecting Kion to AWS billing data. This resource creates commercial AWS billing sources (account type 1). + WARNING: Updates to this resource use a private API endpoint (/v1/payer) that may change without notice. Use at your own risk. +--- + +# kion_billing_source_aws (Resource) + +Creates and manages an AWS commercial billing source. + +AWS billing sources enable cost management and account management capabilities by connecting Kion to AWS billing data. This resource creates commercial AWS billing sources (account type 1). + +**WARNING**: Updates to this resource use a private API endpoint (/v1/payer) that may change without notice. Use at your own risk. + +## Example Usage + +```terraform +# Example: Basic AWS billing source with CUR reports +resource "kion_billing_source_aws" "example" { + name = "Production AWS Billing" + aws_account_number = "123456789012" + billing_start_date = "2024-01" + account_creation = true + + # CUR configuration + billing_report_type = "cur" + cur_bucket = "my-billing-reports-bucket" + cur_bucket_region = "us-east-1" + cur_name = "my-cost-and-usage-report" + cur_prefix = "reports" +} + +# Example: AWS billing source with FOCUS reports +resource "kion_billing_source_aws" "focus_example" { + name = "AWS with FOCUS Reports" + aws_account_number = "987654321098" + billing_start_date = "2024-01" + + # FOCUS billing configuration + billing_report_type = "focus" + focus_billing_bucket_account_number = "987654321098" + focus_billing_report_bucket = "my-focus-reports-bucket" + focus_billing_report_bucket_region = "us-east-1" + focus_billing_report_name = "my-focus-report" + focus_billing_report_prefix = "focus-reports" +} + +# Example: AWS billing source with IAM role access +resource "kion_billing_source_aws" "role_based" { + name = "AWS with IAM Role Access" + aws_account_number = "111222333444" + billing_bucket_account_number = "555666777888" # Different account holds the billing data + billing_start_date = "2024-01" + + # Use IAM role instead of access keys + bucket_access_role = "BillingReportAccessRole" + linked_role = "OrganizationAccountAccessRole" + + # CUR configuration + billing_report_type = "cur" + cur_bucket = "cross-account-billing-reports" + cur_bucket_region = "us-east-1" + cur_name = "organization-cur-report" + cur_prefix = "cur" +} + +# Example: AWS billing source with access keys +resource "kion_billing_source_aws" "key_based" { + name = "AWS with Access Keys" + aws_account_number = "999888777666" + billing_start_date = "2024-01" + + # Authentication via access keys + key_id = var.aws_access_key_id + key_secret = var.aws_secret_access_key + + # Skip validation during creation + skip_validation = true + + # DBR configuration + billing_report_type = "dbrrt" + detailed_billing_bucket = "detailed-billing-reports" + billing_region = "us-west-2" +} +``` + + +## Schema + +### Required + +- `aws_account_number` (String) The AWS account number of the master billing account. +- `billing_start_date` (String) The start date for billing data collection in YYYY-MM format. +- `name` (String) The name of the billing source. + +### Optional + +- `account_creation` (Boolean) When true, Kion is able to automatically create accounts in this billing source. +- `billing_bucket_account_number` (String) The AWS account number of the S3 bucket holding the billing reports. Defaults to aws_account_number if not specified. +- `billing_region` (String) The region of the S3 bucket holding billing reports (both CUR and DBR reports). +- `billing_report_type` (String) The billing report type to use. Options: 'cur' (AWS Cost and Usage Report), 'dbrrt' (AWS Detailed Billing Report with Resources and Tags), 'focus' (FOCUS billing reports). +- `bucket_access_role` (String) An alternate IAM role for accessing the billing buckets (optional). +- `cur_bucket` (String) The name of the S3 bucket containing the Cost and Usage Reports. Required if billing_report_type is 'cur'. +- `cur_bucket_region` (String) The region of the S3 bucket containing the Cost and Usage Reports. Required if billing_report_type is 'cur'. +- `cur_name` (String) The name of the Cost and Usage Report. Required if billing_report_type is 'cur'. +- `cur_prefix` (String) The report prefix for the Cost and Usage Reports. Required if billing_report_type is 'cur'. +- `detailed_billing_bucket` (String) The name of the S3 bucket containing the detailed billing reports. Required if billing_report_type is 'dbrrt'. +- `focus_billing_bucket_account_number` (String) The AWS account number of the S3 bucket holding the FOCUS reports. +- `focus_billing_report_bucket` (String) The name of the S3 bucket containing the FOCUS reports. +- `focus_billing_report_bucket_region` (String) The region of the S3 bucket containing the FOCUS reports. +- `focus_billing_report_name` (String) The name of the FOCUS billing report. +- `focus_billing_report_prefix` (String) The prefix for the FOCUS billing reports. +- `focus_bucket_access_role` (String) An alternate IAM role for accessing the FOCUS billing buckets (optional). +- `key_id` (String, Sensitive) The AWS Access Key ID used to access the billing S3 bucket. +- `key_secret` (String, Sensitive) The AWS Secret Access Key used to access the billing S3 bucket. +- `linked_role` (String) The name of an existing IAM role that has full administrator permissions. This role will be prefilled as the linked role when creating or importing new accounts under this billing source. +- `skip_validation` (Boolean) When true, will skip validating the connection to the billing source during creation. + +### Read-Only + +- `id` (String) The ID of this resource. +- `use_focus_reports` (Boolean) True if billing source is configured to read FOCUS reports. +- `use_proprietary_reports` (Boolean) True if billing source is configured to read proprietary billing reports from AWS (CUR, DBRRT). diff --git a/docs/resources/billing_source_gcp.md b/docs/resources/billing_source_gcp.md new file mode 100644 index 00000000..5b1e2fa3 --- /dev/null +++ b/docs/resources/billing_source_gcp.md @@ -0,0 +1,95 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kion_billing_source_gcp Resource - terraform-provider-kion" +subcategory: "" +description: |- + Creates and manages a GCP (Google Cloud Platform) billing source in Kion. + GCP billing sources are used to import billing data from Google Cloud Platform projects into Kion for cost management and reporting purposes. The billing data is exported from BigQuery where Google Cloud exports billing information. + WARNING: Updates to this resource use a private API endpoint that may change without notice. Use at your own risk. +--- + +# kion_billing_source_gcp (Resource) + +Creates and manages a GCP (Google Cloud Platform) billing source in Kion. + +GCP billing sources are used to import billing data from Google Cloud Platform projects into Kion for cost management and reporting purposes. The billing data is exported from BigQuery where Google Cloud exports billing information. + +**WARNING**: Updates to this resource use a private API endpoint that may change without notice. Use at your own risk. + +## Example Usage + +```terraform +# Create a GCP billing source +# Note: You must first create a GCP service account through the Kion UI or API +# and obtain its ID to use in the service_account_id field +resource "kion_billing_source_gcp" "example" { + name = "My GCP Billing Account" + service_account_id = 123 # ID of the GCP service account created in Kion + gcp_id = "012345-ABCDEF-GHIJKL" # Your GCP billing account ID + billing_start_date = "2024-01" + + # BigQuery export configuration - where GCP exports billing data + big_query_export { + gcp_project_id = "my-billing-project" + dataset_name = "cloud_billing_export" + table_name = "gcp_billing_export_v1" + table_format = "standard" # Options: auto, standard, detailed + focus_view_name = "focus_view_v1" # Optional: Only if using FOCUS + } + + # Optional: Configure billing data format preferences + use_focus = true # Use FOCUS format for cost data + use_proprietary = true # Use GCP proprietary billing format + is_reseller = false # Set to true if this is a reseller billing account +} + +# Example with minimal configuration +resource "kion_billing_source_gcp" "minimal" { + name = "Simple GCP Billing" + service_account_id = 456 # ID of the GCP service account created in Kion + gcp_id = "987654-ZYXWVU-TSRQPO" + billing_start_date = "2024-06" + + big_query_export { + gcp_project_id = "billing-exports" + dataset_name = "billing_data" + table_name = "cost_export" + } +} +``` + + +## Schema + +### Required + +- `big_query_export` (Block List, Min: 1, Max: 1) BigQuery export configuration for billing data. (see [below for nested schema](#nestedblock--big_query_export)) +- `billing_start_date` (String) The start date for billing data collection in YYYY-MM format. +- `gcp_id` (String) The GCP ID of the billing account (e.g., '012345-678901-ABCDEF'). +- `name` (String) The name of the GCP billing source. +- `service_account_id` (Number) The ID of the GCP service account used for authentication. + +### Optional + +- `account_type_id` (Number) The account type ID for the GCP billing source. Defaults to 15 (Google Cloud). +- `is_reseller` (Boolean) Denotes if the billing account is that of a Parent Reseller Billing Account. +- `use_focus` (Boolean) Use GCP FOCUS view for billing data. +- `use_proprietary` (Boolean) Use the GCP Proprietary Billing Table. + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `big_query_export` + +Required: + +- `dataset_name` (String) The name of the BigQuery dataset where the export lives. +- `gcp_project_id` (String) The ID of the GCP project where the BigQuery dataset lives. +- `table_name` (String) The name of the BigQuery table where the export lives. + +Optional: + +- `focus_view_name` (String) The name of the FOCUS view in BigQuery. +- `table_format` (String) The format of the BigQuery table where the export lives. One of 'auto', 'standard' or 'detailed'. diff --git a/docs/resources/billing_source_oci.md b/docs/resources/billing_source_oci.md new file mode 100644 index 00000000..20b9ef72 --- /dev/null +++ b/docs/resources/billing_source_oci.md @@ -0,0 +1,82 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kion_billing_source_oci Resource - terraform-provider-kion" +subcategory: "" +description: |- + Creates and manages an OCI (Oracle Cloud Infrastructure) billing source in Kion. + OCI billing sources are used to import billing data from Oracle Cloud Infrastructure tenancies into Kion for cost management and reporting purposes. +--- + +# kion_billing_source_oci (Resource) + +Creates and manages an OCI (Oracle Cloud Infrastructure) billing source in Kion. + +OCI billing sources are used to import billing data from Oracle Cloud Infrastructure tenancies into Kion for cost management and reporting purposes. + +## Example Usage + +```terraform +# Create an OCI Commercial billing source +resource "kion_billing_source_oci" "example" { + name = "My OCI Billing Source" + billing_start_date = "2024-01" + account_type_id = 26 # 26 = OCI Commercial, 27 = OCI Government, 28 = OCI Federal + + # OCI API access credentials + tenancy_ocid = "ocid1.tenancy.oc1..exampleuniqueID" + user_ocid = "ocid1.user.oc1..exampleuniqueID" + fingerprint = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff" + private_key = file("~/.oci/oci_api_key.pem") + region = "us-ashburn-1" + + # Billing configuration + is_parent_tenancy = true + use_focus_reports = true + use_proprietary_reports = true +} + +# Create an OCI Government billing source +resource "kion_billing_source_oci" "gov_example" { + name = "My OCI Gov Billing Source" + billing_start_date = "2024-01" + account_type_id = 27 # OCI Government + + tenancy_ocid = "ocid1.tenancy.oc1..govexampleuniqueID" + user_ocid = "ocid1.user.oc1..govexampleuniqueID" + fingerprint = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff" + private_key = var.oci_private_key # Can use a variable for sensitive data + region = "us-luke-1" # Government region + + is_parent_tenancy = false + use_focus_reports = true + use_proprietary_reports = false + + # Skip validation during creation if needed + skip_validation = false +} +``` + + +## Schema + +### Required + +- `billing_start_date` (String) The start date for billing data collection in YYYY-MM format. +- `name` (String) The name of the OCI billing source. + +### Optional + +- `account_type_id` (Number) The account type ID for the OCI billing source. Valid values are: 26 (OCI Commercial), 27 (OCI Government), 28 (OCI Federal). Defaults to 26. +- `fingerprint` (String) The fingerprint of the API key for authentication. +- `is_parent_tenancy` (Boolean) Indicates whether this billing source is a parent OCI tenancy. +- `private_key` (String, Sensitive) The private key for API authentication. +- `region` (String) The default OCI region for API access. +- `skip_validation` (Boolean) When true, will skip validating the connection to the billing source during creation or update. +- `tenancy_ocid` (String) The OCID of the OCI tenancy. +- `use_focus_reports` (Boolean) If true, Kion will use FOCUS reports for this billing source. +- `use_proprietary_reports` (Boolean) If true, Kion will use proprietary Oracle Cost Reports for this billing source. +- `user_ocid` (String) The OCID of the OCI user for API access. + +### Read-Only + +- `id` (String) The ID of this resource. diff --git a/docs/resources/ou_cloud_access_role.md b/docs/resources/ou_cloud_access_role.md index 5822a38c..69807553 100644 --- a/docs/resources/ou_cloud_access_role.md +++ b/docs/resources/ou_cloud_access_role.md @@ -125,7 +125,6 @@ output "gcp_admin_role_id" { ### Required -- `aws_iam_role_name` (String) - `name` (String) - `ou_id` (Number) @@ -134,6 +133,7 @@ output "gcp_admin_role_id" { - `aws_iam_path` (String) - `aws_iam_permissions_boundary` (Number) - `aws_iam_policies` (Block Set) (see [below for nested schema](#nestedblock--aws_iam_policies)) +- `aws_iam_role_name` (String) - `azure_role_definitions` (Block Set) (see [below for nested schema](#nestedblock--azure_role_definitions)) - `gcp_iam_roles` (Block Set) (see [below for nested schema](#nestedblock--gcp_iam_roles)) - `last_updated` (String) diff --git a/docs/resources/project.md b/docs/resources/project.md index 3a8313fa..e84d666f 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -8,6 +8,8 @@ description: |- # kion_project (Resource) + + ## Example Usage ```terraform @@ -210,7 +212,6 @@ output "production_project_details" { - `id` (String) The ID of this resource. - ### Nested Schema for `budget` Required: @@ -225,7 +226,6 @@ Optional: - `funding_source_ids` (Set of Number) Funding source IDs to use when data is not specified. This value is ignored is data is specified. If specified, the amount is distributed evenly across months and funding sources. Funding sources will be processed in order from first to last. - ### Nested Schema for `budget.data` Required: @@ -238,24 +238,25 @@ Optional: - `funding_source_id` (Number) ID of funding source for the budget entry. - `priority` (Number) Priority order of the budget entry. This is required if funding_source_id is specified - + + ### Nested Schema for `owner_user_group_ids` Optional: - `id` (Number) - + ### Nested Schema for `owner_user_ids` Optional: - `id` (Number) - + ### Nested Schema for `project_funding` Optional: diff --git a/docs/resources/project_cloud_access_role.md b/docs/resources/project_cloud_access_role.md index 38b26da6..efc2d6e3 100644 --- a/docs/resources/project_cloud_access_role.md +++ b/docs/resources/project_cloud_access_role.md @@ -130,7 +130,6 @@ output "analytics_gcp_role_id" { ### Required -- `aws_iam_role_name` (String) - `name` (String) - `project_id` (Number) @@ -141,6 +140,7 @@ output "analytics_gcp_role_id" { - `aws_iam_path` (String) - `aws_iam_permissions_boundary` (Number) - `aws_iam_policies` (Block Set) (see [below for nested schema](#nestedblock--aws_iam_policies)) +- `aws_iam_role_name` (String) - `azure_role_definitions` (Block Set) (see [below for nested schema](#nestedblock--azure_role_definitions)) - `future_accounts` (Boolean) - `gcp_iam_roles` (Block Set) (see [below for nested schema](#nestedblock--gcp_iam_roles)) diff --git a/examples/data-sources/kion_billing_sources/data-source.tf b/examples/data-sources/kion_billing_sources/data-source.tf new file mode 100644 index 00000000..c4e7f3a7 --- /dev/null +++ b/examples/data-sources/kion_billing_sources/data-source.tf @@ -0,0 +1,41 @@ +# Example: Get all billing sources +data "kion_billing_sources" "all" { +} + +# Example: Filter billing sources by name +data "kion_billing_sources" "production" { + filter { + name = "name" + values = ["Production*"] + regex = true + } +} + +# Example: Filter billing sources by type +data "kion_billing_sources" "aws_sources" { + filter { + name = "type" + values = ["aws"] + } +} + +# Example: Filter billing sources that support account creation +data "kion_billing_sources" "account_creation_enabled" { + filter { + name = "account_creation" + values = ["true"] + } +} + +# Output examples +output "all_billing_sources" { + value = data.kion_billing_sources.all.list +} + +output "production_billing_sources" { + value = data.kion_billing_sources.production.list +} + +output "aws_billing_source_names" { + value = [for source in data.kion_billing_sources.aws_sources.list : source.name] +} diff --git a/examples/resources/kion_billing_source_aws/resource.tf b/examples/resources/kion_billing_source_aws/resource.tf new file mode 100644 index 00000000..fdd5610b --- /dev/null +++ b/examples/resources/kion_billing_source_aws/resource.tf @@ -0,0 +1,67 @@ +# Example: Basic AWS billing source with CUR reports +resource "kion_billing_source_aws" "example" { + name = "Production AWS Billing" + aws_account_number = "123456789012" + billing_start_date = "2024-01" + account_creation = true + + # CUR configuration + billing_report_type = "cur" + cur_bucket = "my-billing-reports-bucket" + cur_bucket_region = "us-east-1" + cur_name = "my-cost-and-usage-report" + cur_prefix = "reports" +} + +# Example: AWS billing source with FOCUS reports +resource "kion_billing_source_aws" "focus_example" { + name = "AWS with FOCUS Reports" + aws_account_number = "987654321098" + billing_start_date = "2024-01" + + # FOCUS billing configuration + billing_report_type = "focus" + focus_billing_bucket_account_number = "987654321098" + focus_billing_report_bucket = "my-focus-reports-bucket" + focus_billing_report_bucket_region = "us-east-1" + focus_billing_report_name = "my-focus-report" + focus_billing_report_prefix = "focus-reports" +} + +# Example: AWS billing source with IAM role access +resource "kion_billing_source_aws" "role_based" { + name = "AWS with IAM Role Access" + aws_account_number = "111222333444" + billing_bucket_account_number = "555666777888" # Different account holds the billing data + billing_start_date = "2024-01" + + # Use IAM role instead of access keys + bucket_access_role = "BillingReportAccessRole" + linked_role = "OrganizationAccountAccessRole" + + # CUR configuration + billing_report_type = "cur" + cur_bucket = "cross-account-billing-reports" + cur_bucket_region = "us-east-1" + cur_name = "organization-cur-report" + cur_prefix = "cur" +} + +# Example: AWS billing source with access keys +resource "kion_billing_source_aws" "key_based" { + name = "AWS with Access Keys" + aws_account_number = "999888777666" + billing_start_date = "2024-01" + + # Authentication via access keys + key_id = var.aws_access_key_id + key_secret = var.aws_secret_access_key + + # Skip validation during creation + skip_validation = true + + # DBR configuration + billing_report_type = "dbrrt" + detailed_billing_bucket = "detailed-billing-reports" + billing_region = "us-west-2" +} diff --git a/examples/resources/kion_billing_source_gcp/resource.tf b/examples/resources/kion_billing_source_gcp/resource.tf new file mode 100644 index 00000000..e0187197 --- /dev/null +++ b/examples/resources/kion_billing_source_gcp/resource.tf @@ -0,0 +1,37 @@ +# Create a GCP billing source +# Note: You must first create a GCP service account through the Kion UI or API +# and obtain its ID to use in the service_account_id field +resource "kion_billing_source_gcp" "example" { + name = "My GCP Billing Account" + service_account_id = 123 # ID of the GCP service account created in Kion + gcp_id = "012345-ABCDEF-GHIJKL" # Your GCP billing account ID + billing_start_date = "2024-01" + + # BigQuery export configuration - where GCP exports billing data + big_query_export { + gcp_project_id = "my-billing-project" + dataset_name = "cloud_billing_export" + table_name = "gcp_billing_export_v1" + table_format = "standard" # Options: auto, standard, detailed + focus_view_name = "focus_view_v1" # Optional: Only if using FOCUS + } + + # Optional: Configure billing data format preferences + use_focus = true # Use FOCUS format for cost data + use_proprietary = true # Use GCP proprietary billing format + is_reseller = false # Set to true if this is a reseller billing account +} + +# Example with minimal configuration +resource "kion_billing_source_gcp" "minimal" { + name = "Simple GCP Billing" + service_account_id = 456 # ID of the GCP service account created in Kion + gcp_id = "987654-ZYXWVU-TSRQPO" + billing_start_date = "2024-06" + + big_query_export { + gcp_project_id = "billing-exports" + dataset_name = "billing_data" + table_name = "cost_export" + } +} diff --git a/examples/resources/kion_billing_source_oci/resource.tf b/examples/resources/kion_billing_source_oci/resource.tf new file mode 100644 index 00000000..8d20611f --- /dev/null +++ b/examples/resources/kion_billing_source_oci/resource.tf @@ -0,0 +1,38 @@ +# Create an OCI Commercial billing source +resource "kion_billing_source_oci" "example" { + name = "My OCI Billing Source" + billing_start_date = "2024-01" + account_type_id = 26 # 26 = OCI Commercial, 27 = OCI Government, 28 = OCI Federal + + # OCI API access credentials + tenancy_ocid = "ocid1.tenancy.oc1..exampleuniqueID" + user_ocid = "ocid1.user.oc1..exampleuniqueID" + fingerprint = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff" + private_key = file("~/.oci/oci_api_key.pem") + region = "us-ashburn-1" + + # Billing configuration + is_parent_tenancy = true + use_focus_reports = true + use_proprietary_reports = true +} + +# Create an OCI Government billing source +resource "kion_billing_source_oci" "gov_example" { + name = "My OCI Gov Billing Source" + billing_start_date = "2024-01" + account_type_id = 27 # OCI Government + + tenancy_ocid = "ocid1.tenancy.oc1..govexampleuniqueID" + user_ocid = "ocid1.user.oc1..govexampleuniqueID" + fingerprint = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff" + private_key = var.oci_private_key # Can use a variable for sensitive data + region = "us-luke-1" # Government region + + is_parent_tenancy = false + use_focus_reports = true + use_proprietary_reports = false + + # Skip validation during creation if needed + skip_validation = false +} diff --git a/kion/data_source_billing_sources.go b/kion/data_source_billing_sources.go new file mode 100644 index 00000000..cd813100 --- /dev/null +++ b/kion/data_source_billing_sources.go @@ -0,0 +1,171 @@ +package kion + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + hc "github.com/kionsoftware/terraform-provider-kion/kion/internal/kionclient" +) + +func dataSourceBillingSources() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceBillingSourcesRead, + Schema: map[string]*schema.Schema{ + "filter": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Description: "The field name whose values you wish to filter by.", + Type: schema.TypeString, + Required: true, + }, + "values": { + Description: "The values of the field name you specified.", + Type: schema.TypeList, + Required: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "regex": { + Description: "Dictates if the values provided should be treated as regular expressions.", + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + }, + }, + }, + "list": { + Description: "This is where Kion makes the discovered data available as a list of resources.", + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the billing source.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the billing source.", + }, + "type": { + Type: schema.TypeString, + Computed: true, + Description: "The type of the billing source (aws, azure, gcp, or oci).", + }, + "account_creation": { + Type: schema.TypeBool, + Computed: true, + Description: "When true, Kion is able to create accounts on this payer.", + }, + "use_focus_reports": { + Type: schema.TypeBool, + Computed: true, + Description: "True if billing source is configured to read FOCUS reports.", + }, + "use_proprietary_reports": { + Type: schema.TypeBool, + Computed: true, + Description: "True if billing source is configured to read proprietary billing reports from the CSP.", + }, + }, + }, + }, + }, + } +} + +func dataSourceBillingSourcesRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + client := m.(*hc.Client) + + resp := new(hc.BillingSourceListResponse) + err := client.GET("/v4/billing-source", resp) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to read billing sources", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + return diags + } + + f := hc.NewFilterable(d) + arr := make([]map[string]interface{}, 0) + + for _, item := range resp.Data.Items { + // Determine the billing source type and name + var bsType string + var bsName string + + if item.AWSPayer != nil { + bsType = "aws" + // Extract name from AWS payer + if awsPayerData, ok := (*item.AWSPayer).(map[string]interface{}); ok { + bsName = hc.GetStringFromInterface(awsPayerData["name"]) + } + } else if item.AzurePayer != nil { + bsType = "azure" + // Extract name from Azure payer + if azurePayerData, ok := (*item.AzurePayer).(map[string]interface{}); ok { + bsName = hc.GetStringFromInterface(azurePayerData["name"]) + } + } else if item.GCPPayer != nil { + bsType = "gcp" + // Extract name from GCP payer + if item.GCPPayer.GCPBillingAccount.Name != "" { + bsName = item.GCPPayer.GCPBillingAccount.Name + } + } else if item.OCIPayer != nil { + bsType = "oci" + // Extract name from OCI payer + if item.OCIPayer.Name != "" { + bsName = item.OCIPayer.Name + } + } + + data := map[string]interface{}{ + "id": int(item.ID), + "name": bsName, + "type": bsType, + "account_creation": item.AccountCreation, + "use_focus_reports": item.UseFocusReports, + "use_proprietary_reports": item.UseProprietaryReports, + } + + match, err := f.Match(data) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to filter billing sources", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + continue + } else if !match { + continue + } + + arr = append(arr, data) + } + + if err := d.Set("list", arr); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set list", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + return diags + } + + d.SetId(strconv.FormatInt(time.Now().Unix(), 10)) + + return diags +} diff --git a/kion/internal/kionclient/helper.go b/kion/internal/kionclient/helper.go index 52a5ad3f..34bd2885 100644 --- a/kion/internal/kionclient/helper.go +++ b/kion/internal/kionclient/helper.go @@ -244,6 +244,7 @@ func FieldsChanged(iOld, iNew interface{}, fields []string) (map[string]interfac // OptionalBool retrieves a boolean value from schema.ResourceData by its field name and // returns a pointer to that value. If the field is not set or is not a boolean, it returns nil. func OptionalBool(d *schema.ResourceData, fieldname string) *bool { + //lint:ignore SA1019 GetOkExists is deprecated but still needed for optional bool fields b, ok := d.GetOkExists(fieldname) if !ok { return nil @@ -259,6 +260,7 @@ func OptionalBool(d *schema.ResourceData, fieldname string) *bool { // OptionalInt retrieves an integer value from schema.ResourceData by its field name and // returns a pointer to that value. If the field is not set or is not an integer, it returns nil. func OptionalInt(d *schema.ResourceData, fieldname string) *int { + //lint:ignore SA1019 GetOkExists is deprecated but still needed for optional int fields v, ok := d.GetOkExists(fieldname) if !ok { return nil @@ -274,6 +276,7 @@ func OptionalInt(d *schema.ResourceData, fieldname string) *int { // OptionalValue retrieves a value from schema.ResourceData by its field name and returns a pointer to that value. // The function uses type assertion to handle different types like int, bool, and string. func OptionalValue[T any](d *schema.ResourceData, fieldname string) *T { + //lint:ignore SA1019 GetOkExists is deprecated but still needed for optional generic fields v, ok := d.GetOkExists(fieldname) if !ok { return nil @@ -886,3 +889,13 @@ func AlmostEqual(a, b, tolerance float64) bool { func RoundToTwoDecimals(amount float64) float64 { return float64(int(amount*100+0.5)) / 100 } + +func GetStringFromInterface(v interface{}) string { + if v == nil { + return "" + } + if str, ok := v.(string); ok { + return str + } + return fmt.Sprintf("%v", v) +} diff --git a/kion/internal/kionclient/models_billing_source.go b/kion/internal/kionclient/models_billing_source.go new file mode 100644 index 00000000..738ec350 --- /dev/null +++ b/kion/internal/kionclient/models_billing_source.go @@ -0,0 +1,22 @@ +package kionclient + +// BillingSourceListResponse for: GET /api/v4/billing-source +type BillingSourceListResponse struct { + Data struct { + Items []BillingSource `json:"items"` + Total int `json:"total"` + } `json:"data"` + Status int `json:"status"` +} + +// BillingSource defines the contents of a billing source +type BillingSource struct { + ID uint `json:"id"` + AccountCreation bool `json:"account_creation"` + AWSPayer *interface{} `json:"aws_payer,omitempty"` + AzurePayer *interface{} `json:"azure_payer,omitempty"` + GCPPayer *GCPBillingAccountAugmented `json:"gcp_payer,omitempty"` + OCIPayer *OCIBillingSource `json:"oci_payer,omitempty"` + UseFocusReports bool `json:"use_focus_reports"` + UseProprietaryReports bool `json:"use_proprietary_reports"` +} diff --git a/kion/internal/kionclient/models_billing_source_aws.go b/kion/internal/kionclient/models_billing_source_aws.go new file mode 100644 index 00000000..5a35a8bc --- /dev/null +++ b/kion/internal/kionclient/models_billing_source_aws.go @@ -0,0 +1,133 @@ +package kionclient + +// AWSBillingSourceCreate for creating AWS billing sources +type AWSBillingSourceCreate struct { + Name string `json:"name"` + AWSAccountNumber string `json:"aws_account_number"` + AccountTypeID int `json:"account_type_id"` + AccountCreation bool `json:"account_creation"` + BillingBucketAccountNumber string `json:"billing_bucket_account_number,omitempty"` + BillingRegion string `json:"billing_region,omitempty"` + BillingReportType string `json:"billing_report_type,omitempty"` + BillingStartDate string `json:"billing_start_date"` + BucketAccessRole string `json:"bucket_access_role,omitempty"` + CURBucket string `json:"cur_bucket,omitempty"` + CURBucketRegion string `json:"cur_bucket_region,omitempty"` + CURName string `json:"cur_name,omitempty"` + CURPrefix string `json:"cur_prefix,omitempty"` + FocusBillingBucketAccountNumber string `json:"focus_billing_bucket_account_number,omitempty"` + FocusBillingReportBucket string `json:"focus_billing_report_bucket,omitempty"` + FocusBillingReportBucketRegion string `json:"focus_billing_report_bucket_region,omitempty"` + FocusBillingReportName string `json:"focus_billing_report_name,omitempty"` + FocusBillingReportPrefix string `json:"focus_billing_report_prefix,omitempty"` + FocusBucketAccessRole string `json:"focus_bucket_access_role,omitempty"` + KeyID string `json:"key_id,omitempty"` + KeySecret string `json:"key_secret,omitempty"` + LinkedRole string `json:"linked_role,omitempty"` + MRBucket string `json:"mr_bucket,omitempty"` + SkipValidation bool `json:"skip_validation,omitempty"` +} + +// AWSBillingSourceUpdate for updating AWS billing sources +type AWSBillingSourceUpdate struct { + Name string `json:"name,omitempty"` + AccountCreation *bool `json:"account_creation,omitempty"` + BillingBucketAccountNumber string `json:"billing_bucket_account_number,omitempty"` + BillingRegion string `json:"billing_region,omitempty"` + BillingReportType string `json:"billing_report_type,omitempty"` + BillingStartDate string `json:"billing_start_date,omitempty"` + BucketAccessRole string `json:"bucket_access_role,omitempty"` + CURBucket string `json:"cur_bucket,omitempty"` + CURBucketRegion string `json:"cur_bucket_region,omitempty"` + CURName string `json:"cur_name,omitempty"` + CURPrefix string `json:"cur_prefix,omitempty"` + FocusBillingBucketAccountNumber string `json:"focus_billing_bucket_account_number,omitempty"` + FocusBillingReportBucket string `json:"focus_billing_report_bucket,omitempty"` + FocusBillingReportBucketRegion string `json:"focus_billing_report_bucket_region,omitempty"` + FocusBillingReportName string `json:"focus_billing_report_name,omitempty"` + FocusBillingReportPrefix string `json:"focus_billing_report_prefix,omitempty"` + FocusBucketAccessRole string `json:"focus_bucket_access_role,omitempty"` + KeyID string `json:"key_id,omitempty"` + KeySecret string `json:"key_secret,omitempty"` + LinkedRole string `json:"linked_role,omitempty"` + MRBucket string `json:"mr_bucket,omitempty"` +} + +// AWSPayer represents AWS billing source details +type AWSPayer struct { + ID uint `json:"id"` + Name string `json:"name"` + AccountNumber string `json:"account_number"` + BillingBucketAccountNumber string `json:"billing_bucket_account_number"` + BillingRegion string `json:"billing_region"` + BillingReportBucket string `json:"billing_report_bucket"` + BillingReportBucketRegion string `json:"billing_report_bucket_region"` + BillingReportName string `json:"billing_report_name"` + BillingReportPrefix string `json:"billing_report_prefix"` + BillingReportType string `json:"billing_report_type"` + BillingStartDate string `json:"billing_start_date"` + BucketAccessRole string `json:"bucket_access_role"` + DetailedBillingBucket string `json:"detailed_billing_bucket"` + FOCUSBillingBucketAccountNumber string `json:"focus_billing_bucket_account_number"` + FOCUSBillingReportBucket string `json:"focus_billing_report_bucket"` + FOCUSBillingReportBucketRegion string `json:"focus_billing_report_bucket_region"` + FOCUSBillingReportName string `json:"focus_billing_report_name"` + FOCUSBillingReportPrefix string `json:"focus_billing_report_prefix"` + FOCUSBucketAccessRole string `json:"focus_bucket_access_role"` + OrgID string `json:"org_id"` +} + +// BillingSourceResponse for billing source GET responses +type BillingSourceResponse struct { + Data BillingSourceData `json:"data"` + Status int `json:"status"` +} + +// BillingSourceData contains the billing source details +type BillingSourceData struct { + ID uint `json:"id"` + AccountCreation bool `json:"account_creation"` + AWSPayer *AWSPayer `json:"aws_payer,omitempty"` + UseFocusReports bool `json:"use_focus_reports"` + UseProprietaryReports bool `json:"use_proprietary_reports"` +} + +// AWSBillingSourceV1Update represents the structure for updating billing sources via the v1 API +// WARNING: This uses a private API endpoint (/v1/payer) that may change without notice +type AWSBillingSourceV1Update struct { + ID int `json:"id"` + AccountTypeID int `json:"account_type_id"` + UseFocusReports bool `json:"use_focus_reports"` + UseProprietaryReports bool `json:"use_proprietary_reports"` + AccountCreation bool `json:"account_creation"` + SkipValidation bool `json:"skip_validation"` + AWSPayer AWSBillingSourceV1Payer `json:"aws_payer"` +} + +// AWSBillingSourceV1Payer represents the aws_payer structure for v1 API updates +type AWSBillingSourceV1Payer struct { + ID int `json:"id"` + Name string `json:"name"` + AccountNumber string `json:"account_number"` + KeyID string `json:"key_id"` + KeySecret string `json:"key_secret"` + BillingRegion string `json:"billing_region"` + BillingReportPrefix string `json:"billing_report_prefix"` + BillingReportBucket string `json:"billing_report_bucket"` + BillingReportBucketRegion string `json:"billing_report_bucket_region"` + BillingReportName string `json:"billing_report_name"` + BillingBucketAccountNumber string `json:"billing_bucket_account_number"` + BucketAccessRole string `json:"bucket_access_role"` + DetailedBillingBucket string `json:"detailed_billing_bucket"` + FocusBillingBucketAccountNumber string `json:"focus_billing_bucket_account_number"` + FocusBucketAccessRole string `json:"focus_bucket_access_role"` + FocusBillingReportBucketRegion string `json:"focus_billing_report_bucket_region"` + FocusBillingReportBucket string `json:"focus_billing_report_bucket"` + FocusBillingReportPrefix string `json:"focus_billing_report_prefix"` + FocusBillingReportName string `json:"focus_billing_report_name"` + BillingStartDate int `json:"billing_start_date"` + LinkedRole string `json:"linked_role"` + AutoEnrollSupport bool `json:"auto_enroll_support"` + SupportType *int `json:"support_type"` + BillingReportTypeID int `json:"billing_report_type_id"` +} diff --git a/kion/internal/kionclient/models_gcp_billing_source.go b/kion/internal/kionclient/models_gcp_billing_source.go new file mode 100644 index 00000000..78c582b3 --- /dev/null +++ b/kion/internal/kionclient/models_gcp_billing_source.go @@ -0,0 +1,112 @@ +package kionclient + +// GCPBillingSourceCreate for: POST /v3/billing-source/gcp +type GCPBillingSourceCreate struct { + AccountTypeID uint `json:"account_type_id"` + GCPBillingAccountCreate GCPBillingAccountWithStart `json:"gcp_billing_account_create"` +} + +// GCPBillingAccountWithStart contains fields describing a billing account in GCP with start date +type GCPBillingAccountWithStart struct { + ServiceAccountID uint `json:"service_account_id"` + Name string `json:"name"` + GCPID string `json:"gcp_id"` + BigQueryExport GCPBigQueryExport `json:"big_query_export"` + BillingStartDate string `json:"billing_start_date"` + IsReseller bool `json:"is_reseller,omitempty"` + UseFOCUS bool `json:"use_focus,omitempty"` + UseProprietary bool `json:"use_proprietary,omitempty"` +} + +// GCPBigQueryExport describes information about where billing data is exported in BigQuery +type GCPBigQueryExport struct { + GCPProjectID string `json:"gcp_project_id"` + DatasetName string `json:"dataset_name"` + TableName string `json:"table_name"` + TableFormat string `json:"table_format,omitempty"` + FOCUSViewName string `json:"focus_view_name,omitempty"` +} + +// GCPBillingAccount contains fields describing a billing account in GCP +type GCPBillingAccount struct { + ID uint `json:"id"` + ServiceAccountID uint `json:"service_account_id"` + Name string `json:"name"` + GCPID string `json:"gcp_id"` + BigQueryExport GCPBigQueryExport `json:"big_query_export"` + IsReseller bool `json:"is_reseller"` +} + +// GCPBillingAccountAugmented contains information about retrieving billing data from GCP +type GCPBillingAccountAugmented struct { + GCPBillingAccount GCPBillingAccount `json:"gcp_billing_account"` + GCPServiceAccount GoogleCloudServiceAccountWithKey `json:"gcp_service_account"` +} + +// GoogleCloudServiceAccountWithKey represents a GCP service account with key information +type GoogleCloudServiceAccountWithKey struct { + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Email string `json:"email"` + EnableFederationSupport bool `json:"enable_federation_support"` + ProjectID string `json:"project_id"` + UniqueID string `json:"unique_id"` + OAuthClientID string `json:"oauth_client_id,omitempty"` + // Private key is sensitive and not returned in GET responses +} + +// GoogleCloudServiceAccountCreate for creating a service account +type GoogleCloudServiceAccountCreate struct { + Name string `json:"name"` + Description string `json:"description"` + EnableFederationSupport bool `json:"enable_federation_support"` + JSON string `json:"json,omitempty"` + OAuthClientID string `json:"oauth_client_id,omitempty"` + OAuthClientSecret string `json:"oauth_client_secret,omitempty"` +} + +// GCPBillingSource represents the full GCP billing source including all fields +type GCPBillingSource struct { + ID uint `json:"id"` + Name string `json:"name"` + AccountTypeID uint `json:"account_type_id"` + ServiceAccountID uint `json:"service_account_id"` + GCPID string `json:"gcp_id"` + BillingStartDate string `json:"billing_start_date"` + BigQueryExport GCPBigQueryExport `json:"big_query_export"` + IsReseller bool `json:"is_reseller"` + UseFOCUSReports bool `json:"use_focus_reports"` + UseProprietaryReports bool `json:"use_proprietary_reports"` +} + +// GCPBillingSourceV1Update represents the structure for updating GCP billing sources via the v1 API +// WARNING: This uses a private API endpoint that may change without notice +type GCPBillingSourceV1Update struct { + ID int `json:"id"` + AccountTypeID int `json:"account_type_id"` + UseFocusReports bool `json:"use_focus_reports"` + UseProprietaryReports bool `json:"use_proprietary_reports"` + AccountCreation bool `json:"account_creation"` + SkipValidation bool `json:"skip_validation"` + GCPBillingAccountUpdate GCPBillingAccountV1Update `json:"gcp_billing_account_update"` +} + +// GCPBillingAccountV1Update represents the gcp_billing_account_update structure for v1 API updates +type GCPBillingAccountV1Update struct { + Name string `json:"name"` + ServiceAccountID int `json:"service_account_id"` + GCPID string `json:"gcp_id"` + BillingStartDate int `json:"billing_start_date"` + IsReseller bool `json:"is_reseller"` + BigQueryExport GCPBigQueryExportV1 `json:"big_query_export"` +} + +// GCPBigQueryExportV1 represents the big_query_export structure for v1 API updates +type GCPBigQueryExportV1 struct { + GCPProjectID string `json:"gcp_project_id"` + DatasetName string `json:"dataset_name"` + TableName *string `json:"table_name"` + TableFormat string `json:"table_format"` + FOCUSViewName string `json:"focus_view_name"` +} diff --git a/kion/internal/kionclient/models_oci_billing_source.go b/kion/internal/kionclient/models_oci_billing_source.go new file mode 100644 index 00000000..d1053a83 --- /dev/null +++ b/kion/internal/kionclient/models_oci_billing_source.go @@ -0,0 +1,50 @@ +package kionclient + +// OCIBillingSource represents an OCI billing source +type OCIBillingSource struct { + ID uint `json:"id,omitempty"` + Name string `json:"name"` + AccountTypeID uint `json:"account_type_id"` + BillingStartDate string `json:"billing_start_date"` + TenancyOCID string `json:"tenancy_ocid"` + UserOCID string `json:"user_ocid"` + Fingerprint string `json:"fingerprint"` + PrivateKey string `json:"private_key"` + Region string `json:"region"` + IsParentTenancy bool `json:"is_parent_tenancy"` + UseFOCUSReports bool `json:"use_focus_reports"` + UseProprietaryReports bool `json:"use_proprietary_reports"` +} + +// OCIBillingSourceCreate represents the payload for creating an OCI billing source +type OCIBillingSourceCreate struct { + Name string `json:"name"` + AccountTypeID uint `json:"account_type_id"` + BillingStartDate string `json:"billing_start_date"` + TenancyOCID string `json:"tenancy_ocid,omitempty"` + UserOCID string `json:"user_ocid,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + PrivateKey string `json:"private_key,omitempty"` + Region string `json:"region,omitempty"` + IsParentTenancy bool `json:"is_parent_tenancy"` + UseFOCUSReports bool `json:"use_focus_reports"` + UseProprietaryReports bool `json:"use_proprietary_reports"` + SkipValidation bool `json:"skip_validation,omitempty"` +} + +// OCIBillingSourceUpdate represents the payload for updating an OCI billing source +type OCIBillingSourceUpdate struct { + ID uint `json:"id,omitempty"` + Name string `json:"name,omitempty"` + AccountTypeID uint `json:"account_type_id,omitempty"` + BillingStartDate string `json:"billing_start_date,omitempty"` + TenancyOCID string `json:"tenancy_ocid,omitempty"` + UserOCID string `json:"user_ocid,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + PrivateKey string `json:"private_key,omitempty"` + Region string `json:"region,omitempty"` + IsParentTenancy bool `json:"is_parent_tenancy"` + UseFOCUSReports bool `json:"use_focus_reports"` + UseProprietaryReports bool `json:"use_proprietary_reports"` + SkipValidation bool `json:"skip_validation,omitempty"` +} diff --git a/kion/internal/kionclient/models_ou_cloud_access_role.go b/kion/internal/kionclient/models_ou_cloud_access_role.go index 2844a8d6..b41672eb 100644 --- a/kion/internal/kionclient/models_ou_cloud_access_role.go +++ b/kion/internal/kionclient/models_ou_cloud_access_role.go @@ -9,7 +9,7 @@ type OUCloudAccessRoleResponse struct { GCPIamRoles []ObjectWithID `json:"gcp_iam_roles"` OUCloudAccessRole struct { AwsIamPath string `json:"aws_iam_path"` - AwsIamRoleName string `json:"aws_iam_role_name"` + AwsIamRoleName *string `json:"aws_iam_role_name,omitempty"` ID int `json:"id"` LongTermAccessKeys bool `json:"long_term_access_keys"` Name string `json:"name"` @@ -28,7 +28,7 @@ type OUCloudAccessRoleCreate struct { AwsIamPath string `json:"aws_iam_path"` AwsIamPermissionsBoundary *int `json:"aws_iam_permissions_boundary"` AwsIamPolicies *[]int `json:"aws_iam_policies"` - AwsIamRoleName string `json:"aws_iam_role_name"` + AwsIamRoleName *string `json:"aws_iam_role_name,omitempty"` AzureRoleDefinitions *[]int `json:"azure_role_definitions"` GCPIamRoles *[]int `json:"gcp_iam_roles"` LongTermAccessKeys bool `json:"long_term_access_keys"` diff --git a/kion/internal/kionclient/models_project_cloud_access_role.go b/kion/internal/kionclient/models_project_cloud_access_role.go index 802aae77..96b8c2a2 100644 --- a/kion/internal/kionclient/models_project_cloud_access_role.go +++ b/kion/internal/kionclient/models_project_cloud_access_role.go @@ -11,7 +11,7 @@ type ProjectCloudAccessRoleResponse struct { ProjectCloudAccessRole struct { ApplyToAllAccounts bool `json:"apply_to_all_accounts"` AwsIamPath string `json:"aws_iam_path"` - AwsIamRoleName string `json:"aws_iam_role_name"` + AwsIamRoleName *string `json:"aws_iam_role_name,omitempty"` FutureAccounts bool `json:"future_accounts"` ID int `json:"id"` LongTermAccessKeys bool `json:"long_term_access_keys"` @@ -33,7 +33,7 @@ type ProjectCloudAccessRoleCreate struct { AwsIamPath string `json:"aws_iam_path"` AwsIamPermissionsBoundary *int `json:"aws_iam_permissions_boundary"` AwsIamPolicies *[]int `json:"aws_iam_policies"` - AwsIamRoleName string `json:"aws_iam_role_name"` + AwsIamRoleName *string `json:"aws_iam_role_name,omitempty"` AzureRoleDefinitions *[]int `json:"azure_role_definitions"` FutureAccounts bool `json:"future_accounts"` GCPIamRoles *[]int `json:"gcp_iam_roles"` diff --git a/kion/provider.go b/kion/provider.go index 29cd9e02..39a7460f 100644 --- a/kion/provider.go +++ b/kion/provider.go @@ -50,6 +50,9 @@ func Provider() *schema.Provider { "kion_azure_arm_template": resourceAzureArmTemplate(), "kion_azure_policy": resourceAzurePolicy(), "kion_azure_role": resourceAzureRole(), + "kion_billing_source_aws": resourceBillingSourceAws(), + "kion_billing_source_gcp": resourceBillingSourceGcp(), + "kion_billing_source_oci": resourceBillingSourceOci(), "kion_cloud_rule": resourceCloudRule(), "kion_compliance_check": resourceComplianceCheck(), "kion_compliance_standard": resourceComplianceStandard(), @@ -82,6 +85,7 @@ func Provider() *schema.Provider { "kion_azure_arm_template": dataSourceAzureArmTemplate(), "kion_azure_policy": dataSourceAzurePolicy(), "kion_azure_role": dataSourceAzureRole(), + "kion_billing_sources": dataSourceBillingSources(), "kion_cached_account": dataSourceCachedAccount(), "kion_cloud_rule": dataSourceCloudRule(), "kion_compliance_check": dataSourceComplianceCheck(), diff --git a/kion/resource_billing_source_aws.go b/kion/resource_billing_source_aws.go new file mode 100644 index 00000000..6da57986 --- /dev/null +++ b/kion/resource_billing_source_aws.go @@ -0,0 +1,953 @@ +package kion + +import ( + "context" + "fmt" + "regexp" + "strconv" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + hc "github.com/kionsoftware/terraform-provider-kion/kion/internal/kionclient" +) + +func resourceBillingSourceAws() *schema.Resource { + return &schema.Resource{ + Description: "Creates and manages an AWS commercial billing source.\n\n" + + "AWS billing sources enable cost management and account management capabilities " + + "by connecting Kion to AWS billing data. This resource creates commercial AWS " + + "billing sources (account type 1).\n\n" + + "**WARNING**: Updates to this resource use a private API endpoint (/v1/payer) " + + "that may change without notice. Use at your own risk.", + CreateContext: resourceBillingSourceAwsCreate, + ReadContext: resourceBillingSourceAwsRead, + UpdateContext: resourceBillingSourceAwsUpdate, + DeleteContext: resourceBillingSourceAwsDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + CustomizeDiff: func(ctx context.Context, diff *schema.ResourceDiff, meta interface{}) error { + return validateBillingSourceAwsFields(diff) + }, + Schema: map[string]*schema.Schema{ + // Required fields + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the billing source.", + }, + "aws_account_number": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The AWS account number of the master billing account.", + ValidateFunc: validation.StringMatch( + regexp.MustCompile(`^\d{12}$`), + "must be a 12-digit AWS account number", + ), + }, + "billing_start_date": { + Type: schema.TypeString, + Required: true, + Description: "The start date for billing data collection in YYYY-MM format.", + ValidateFunc: validation.StringMatch( + regexp.MustCompile(`^\d{4}-(?:0[1-9]|1[0-2])$`), + "must be in YYYY-MM format", + ), + }, + + // Optional fields + "account_creation": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "When true, Kion is able to automatically create accounts in this billing source.", + }, + "billing_bucket_account_number": { + Type: schema.TypeString, + Optional: true, + Description: "The AWS account number of the S3 bucket holding the billing reports. Defaults to aws_account_number if not specified.", + ValidateFunc: validation.StringMatch( + regexp.MustCompile(`^\d{12}$`), + "must be a 12-digit AWS account number", + ), + }, + "billing_region": { + Type: schema.TypeString, + Optional: true, + Description: "The region of the S3 bucket holding billing reports (both CUR and DBR reports).", + }, + "billing_report_type": { + Type: schema.TypeString, + Optional: true, + Default: "cur", + Description: "The billing report type to use. Options: 'cur' (AWS Cost and Usage Report), 'dbrrt' (AWS Detailed Billing Report with Resources and Tags), 'focus' (FOCUS billing reports).", + ValidateFunc: validation.StringInSlice([]string{"cur", "dbrrt", "focus"}, false), + }, + "bucket_access_role": { + Type: schema.TypeString, + Optional: true, + Description: "An alternate IAM role for accessing the billing buckets (optional).", + }, + + // CUR-specific fields + "cur_bucket": { + Type: schema.TypeString, + Optional: true, + Description: "The name of the S3 bucket containing the Cost and Usage Reports. Required if billing_report_type is 'cur'.", + }, + "cur_bucket_region": { + Type: schema.TypeString, + Optional: true, + Description: "The region of the S3 bucket containing the Cost and Usage Reports. Required if billing_report_type is 'cur'.", + }, + "cur_name": { + Type: schema.TypeString, + Optional: true, + Description: "The name of the Cost and Usage Report. Required if billing_report_type is 'cur'.", + }, + "cur_prefix": { + Type: schema.TypeString, + Optional: true, + Description: "The report prefix for the Cost and Usage Reports. Required if billing_report_type is 'cur'.", + }, + + // FOCUS billing fields + "focus_billing_bucket_account_number": { + Type: schema.TypeString, + Optional: true, + Description: "The AWS account number of the S3 bucket holding the FOCUS reports.", + ValidateFunc: validation.StringMatch( + regexp.MustCompile(`^\d{12}$`), + "must be a 12-digit AWS account number", + ), + }, + "focus_billing_report_bucket": { + Type: schema.TypeString, + Optional: true, + Description: "The name of the S3 bucket containing the FOCUS reports.", + }, + "focus_billing_report_bucket_region": { + Type: schema.TypeString, + Optional: true, + Description: "The region of the S3 bucket containing the FOCUS reports.", + }, + "focus_billing_report_name": { + Type: schema.TypeString, + Optional: true, + Description: "The name of the FOCUS billing report.", + }, + "focus_billing_report_prefix": { + Type: schema.TypeString, + Optional: true, + Description: "The prefix for the FOCUS billing reports.", + }, + "focus_bucket_access_role": { + Type: schema.TypeString, + Optional: true, + Description: "An alternate IAM role for accessing the FOCUS billing buckets (optional).", + }, + + // Authentication fields + "key_id": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + Description: "The AWS Access Key ID used to access the billing S3 bucket.", + }, + "key_secret": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + Description: "The AWS Secret Access Key used to access the billing S3 bucket.", + }, + + // Other optional fields + "linked_role": { + Type: schema.TypeString, + Optional: true, + Default: "OrganizationAccountAccessRole", + Description: "The name of an existing IAM role that has full administrator permissions. This role will be prefilled as the linked role when creating or importing new accounts under this billing source.", + }, + "detailed_billing_bucket": { + Type: schema.TypeString, + Optional: true, + Description: "The name of the S3 bucket containing the detailed billing reports. Required if billing_report_type is 'dbrrt'.", + }, + "skip_validation": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "When true, will skip validating the connection to the billing source during creation.", + }, + + // Computed fields + "use_focus_reports": { + Type: schema.TypeBool, + Computed: true, + Description: "True if billing source is configured to read FOCUS reports.", + }, + "use_proprietary_reports": { + Type: schema.TypeBool, + Computed: true, + Description: "True if billing source is configured to read proprietary billing reports from AWS (CUR, DBRRT).", + }, + }, + } +} + +func resourceBillingSourceAwsCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + client := m.(*hc.Client) + + // Validate conditional requirements + billingReportType := d.Get("billing_report_type").(string) + if billingReportType == "cur" { + // All CUR-specific required fields + requiredCurFields := map[string]string{ + "cur_bucket": "The S3 bucket containing the Cost and Usage Reports", + "cur_bucket_region": "The region of the S3 bucket containing the Cost and Usage Reports", + "cur_name": "The name of the Cost and Usage Report", + "cur_prefix": "The report prefix for the Cost and Usage Reports", + } + + for field, description := range requiredCurFields { + if _, ok := d.GetOk(field); !ok { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Missing required field", + Detail: fmt.Sprintf("%s is required when billing_report_type is 'cur'. %s.", field, description), + }) + } + } + if len(diags) > 0 { + return diags + } + } + + if billingReportType == "focus" { + // All FOCUS-specific required fields + requiredFocusFields := map[string]string{ + "focus_billing_report_bucket": "The S3 bucket containing the FOCUS reports", + "focus_billing_report_bucket_region": "The region of the S3 bucket containing the FOCUS reports", + "focus_billing_report_name": "The name of the FOCUS billing report", + "focus_billing_report_prefix": "The prefix for the FOCUS billing reports", + } + + for field, description := range requiredFocusFields { + if _, ok := d.GetOk(field); !ok { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Missing required field", + Detail: fmt.Sprintf("%s is required when billing_report_type is 'focus'. %s.", field, description), + }) + } + } + if len(diags) > 0 { + return diags + } + } + + if billingReportType == "dbrrt" { + // DBR (Detailed Billing Report) required field + if _, ok := d.GetOk("detailed_billing_bucket"); !ok { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Missing required field", + Detail: "detailed_billing_bucket is required when billing_report_type is 'dbrrt'. The S3 bucket containing the detailed billing reports.", + }) + return diags + } + } + + // Build the create request + post := hc.AWSBillingSourceCreate{ + Name: d.Get("name").(string), + AWSAccountNumber: d.Get("aws_account_number").(string), + AccountTypeID: 1, // AWS Commercial + AccountCreation: d.Get("account_creation").(bool), + BillingStartDate: d.Get("billing_start_date").(string), + LinkedRole: d.Get("linked_role").(string), + SkipValidation: d.Get("skip_validation").(bool), + } + + // Set billing bucket account number (defaults to aws_account_number if not specified) + if v, ok := d.GetOk("billing_bucket_account_number"); ok { + post.BillingBucketAccountNumber = v.(string) + } else { + post.BillingBucketAccountNumber = d.Get("aws_account_number").(string) + } + + // Set optional fields using helper functions + if region := hc.FlattenStringPointer(d, "billing_region"); region != nil { + post.BillingRegion = *region + } + if reportType := hc.FlattenStringPointer(d, "billing_report_type"); reportType != nil { + post.BillingReportType = *reportType + } + if bucketRole := hc.FlattenStringPointer(d, "bucket_access_role"); bucketRole != nil { + post.BucketAccessRole = *bucketRole + } + + // CUR-specific fields using helper functions + if curBucket := hc.FlattenStringPointer(d, "cur_bucket"); curBucket != nil { + post.CURBucket = *curBucket + } + if curRegion := hc.FlattenStringPointer(d, "cur_bucket_region"); curRegion != nil { + post.CURBucketRegion = *curRegion + } + if curName := hc.FlattenStringPointer(d, "cur_name"); curName != nil { + post.CURName = *curName + } + if curPrefix := hc.FlattenStringPointer(d, "cur_prefix"); curPrefix != nil { + post.CURPrefix = *curPrefix + } + + // FOCUS billing fields + if v, ok := d.GetOk("focus_billing_bucket_account_number"); ok { + post.FocusBillingBucketAccountNumber = v.(string) + } + if v, ok := d.GetOk("focus_billing_report_bucket"); ok { + post.FocusBillingReportBucket = v.(string) + } + if v, ok := d.GetOk("focus_billing_report_bucket_region"); ok { + post.FocusBillingReportBucketRegion = v.(string) + } + if v, ok := d.GetOk("focus_billing_report_name"); ok { + post.FocusBillingReportName = v.(string) + } + if v, ok := d.GetOk("focus_billing_report_prefix"); ok { + post.FocusBillingReportPrefix = v.(string) + } + if v, ok := d.GetOk("focus_bucket_access_role"); ok { + post.FocusBucketAccessRole = v.(string) + } + + // Authentication fields + if v, ok := d.GetOk("key_id"); ok { + post.KeyID = v.(string) + } + if v, ok := d.GetOk("key_secret"); ok { + post.KeySecret = v.(string) + } + + // Other optional fields + if v, ok := d.GetOk("detailed_billing_bucket"); ok { + post.MRBucket = v.(string) + } + + resp, err := client.POST("/v3/billing-source/aws", post) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to create AWS billing source", + Detail: fmt.Sprintf("Error: %v\nItem: %v", err.Error(), post), + }) + return diags + } + + d.SetId(fmt.Sprintf("%d", resp.RecordID)) + + resourceBillingSourceAwsRead(ctx, d, m) + + return diags +} + +func resourceBillingSourceAwsRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + client := m.(*hc.Client) + + ID := d.Id() + + // Get billing source by ID using the list endpoint since direct ID endpoint doesn't exist + billingSourceID, err := strconv.Atoi(ID) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to parse billing source ID", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + return diags + } + + resp := new(hc.BillingSourceListResponse) + err = client.GET("/v4/billing-source", resp) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to list billing sources", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + return diags + } + + // Find the billing source with matching ID + var billingSource hc.BillingSource + found := false + for _, bs := range resp.Data.Items { + if bs.ID == uint(billingSourceID) { + billingSource = bs + found = true + break + } + } + + if !found { + d.SetId("") + return diags + } + + // Verify this is an AWS billing source + if billingSource.AWSPayer == nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Billing source is not an AWS billing source", + Detail: fmt.Sprintf("Billing source ID %v is not configured as an AWS billing source", ID), + }) + return diags + } + + // Extract AWS payer data from interface{} + awsPayerInterface := *billingSource.AWSPayer + awsPayerMap, ok := awsPayerInterface.(map[string]interface{}) + if !ok { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to parse AWS payer data", + Detail: fmt.Sprintf("AWS payer data is not in expected format for billing source ID %v", ID), + }) + return diags + } + + // Set basic fields using helper function + diags = append(diags, hc.SafeSet(d, "name", hc.GetStringFromInterface(awsPayerMap["name"]), "Unable to set name")...) + diags = append(diags, hc.SafeSet(d, "aws_account_number", hc.GetStringFromInterface(awsPayerMap["account_number"]), "Unable to set aws_account_number")...) + diags = append(diags, hc.SafeSet(d, "account_creation", billingSource.AccountCreation, "Unable to set account_creation")...) + + // Set other fields from AWSPayer using helper function + if billingBucketAccountNumber := hc.GetStringFromInterface(awsPayerMap["billing_bucket_account_number"]); billingBucketAccountNumber != "" { + diags = append(diags, hc.SafeSet(d, "billing_bucket_account_number", billingBucketAccountNumber, "Unable to set billing_bucket_account_number")...) + } + + if billingRegion := hc.GetStringFromInterface(awsPayerMap["billing_region"]); billingRegion != "" { + diags = append(diags, hc.SafeSet(d, "billing_region", billingRegion, "Unable to set billing_region")...) + } + + if billingReportType := hc.GetStringFromInterface(awsPayerMap["billing_report_type"]); billingReportType != "" { + if err := d.Set("billing_report_type", billingReportType); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set billing_report_type", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + if billingStartDate := hc.GetStringFromInterface(awsPayerMap["billing_start_date"]); billingStartDate != "" { + if err := d.Set("billing_start_date", billingStartDate); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set billing_start_date", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + if bucketAccessRole := hc.GetStringFromInterface(awsPayerMap["bucket_access_role"]); bucketAccessRole != "" { + if err := d.Set("bucket_access_role", bucketAccessRole); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set bucket_access_role", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + // CUR fields + if billingReportBucket := hc.GetStringFromInterface(awsPayerMap["billing_report_bucket"]); billingReportBucket != "" { + if err := d.Set("cur_bucket", billingReportBucket); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set cur_bucket", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + if billingReportBucketRegion := hc.GetStringFromInterface(awsPayerMap["billing_report_bucket_region"]); billingReportBucketRegion != "" { + if err := d.Set("cur_bucket_region", billingReportBucketRegion); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set cur_bucket_region", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + if billingReportName := hc.GetStringFromInterface(awsPayerMap["billing_report_name"]); billingReportName != "" { + if err := d.Set("cur_name", billingReportName); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set cur_name", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + if billingReportPrefix := hc.GetStringFromInterface(awsPayerMap["billing_report_prefix"]); billingReportPrefix != "" { + if err := d.Set("cur_prefix", billingReportPrefix); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set cur_prefix", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + // FOCUS fields + if focusBillingBucketAccountNumber := hc.GetStringFromInterface(awsPayerMap["focus_billing_bucket_account_number"]); focusBillingBucketAccountNumber != "" { + if err := d.Set("focus_billing_bucket_account_number", focusBillingBucketAccountNumber); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set focus_billing_bucket_account_number", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + if focusBillingReportBucket := hc.GetStringFromInterface(awsPayerMap["focus_billing_report_bucket"]); focusBillingReportBucket != "" { + if err := d.Set("focus_billing_report_bucket", focusBillingReportBucket); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set focus_billing_report_bucket", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + if focusBillingReportBucketRegion := hc.GetStringFromInterface(awsPayerMap["focus_billing_report_bucket_region"]); focusBillingReportBucketRegion != "" { + if err := d.Set("focus_billing_report_bucket_region", focusBillingReportBucketRegion); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set focus_billing_report_bucket_region", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + if focusBillingReportName := hc.GetStringFromInterface(awsPayerMap["focus_billing_report_name"]); focusBillingReportName != "" { + if err := d.Set("focus_billing_report_name", focusBillingReportName); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set focus_billing_report_name", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + if focusBillingReportPrefix := hc.GetStringFromInterface(awsPayerMap["focus_billing_report_prefix"]); focusBillingReportPrefix != "" { + if err := d.Set("focus_billing_report_prefix", focusBillingReportPrefix); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set focus_billing_report_prefix", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + if focusBucketAccessRole := hc.GetStringFromInterface(awsPayerMap["focus_bucket_access_role"]); focusBucketAccessRole != "" { + if err := d.Set("focus_bucket_access_role", focusBucketAccessRole); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set focus_bucket_access_role", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + // Other fields + if detailedBillingBucket := hc.GetStringFromInterface(awsPayerMap["detailed_billing_bucket"]); detailedBillingBucket != "" { + if err := d.Set("detailed_billing_bucket", detailedBillingBucket); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set detailed_billing_bucket", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + } + + // Computed fields + if err := d.Set("use_focus_reports", billingSource.UseFocusReports); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set use_focus_reports", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + + if err := d.Set("use_proprietary_reports", billingSource.UseProprietaryReports); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to set use_proprietary_reports", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + } + + // Ensure the ID is set to the billing source ID + d.SetId(fmt.Sprintf("%d", billingSource.ID)) + + return diags +} + +func resourceBillingSourceAwsUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + + // Check what has changed and build the update request + hasChanged := false + update := hc.AWSBillingSourceUpdate{} + + if d.HasChange("name") { + hasChanged = true + update.Name = d.Get("name").(string) + } + + if d.HasChange("account_creation") { + hasChanged = true + accountCreation := d.Get("account_creation").(bool) + update.AccountCreation = &accountCreation + } + + if d.HasChange("billing_bucket_account_number") { + hasChanged = true + update.BillingBucketAccountNumber = d.Get("billing_bucket_account_number").(string) + } + + if d.HasChange("billing_region") { + hasChanged = true + update.BillingRegion = d.Get("billing_region").(string) + } + + if d.HasChange("billing_report_type") { + hasChanged = true + update.BillingReportType = d.Get("billing_report_type").(string) + } + + if d.HasChange("billing_start_date") { + hasChanged = true + update.BillingStartDate = d.Get("billing_start_date").(string) + } + + if d.HasChange("bucket_access_role") { + hasChanged = true + update.BucketAccessRole = d.Get("bucket_access_role").(string) + } + + // CUR fields + if d.HasChange("cur_bucket") { + hasChanged = true + update.CURBucket = d.Get("cur_bucket").(string) + } + + if d.HasChange("cur_bucket_region") { + hasChanged = true + update.CURBucketRegion = d.Get("cur_bucket_region").(string) + } + + if d.HasChange("cur_name") { + hasChanged = true + update.CURName = d.Get("cur_name").(string) + } + + if d.HasChange("cur_prefix") { + hasChanged = true + update.CURPrefix = d.Get("cur_prefix").(string) + } + + // FOCUS fields + if d.HasChange("focus_billing_bucket_account_number") { + hasChanged = true + update.FocusBillingBucketAccountNumber = d.Get("focus_billing_bucket_account_number").(string) + } + + if d.HasChange("focus_billing_report_bucket") { + hasChanged = true + update.FocusBillingReportBucket = d.Get("focus_billing_report_bucket").(string) + } + + if d.HasChange("focus_billing_report_bucket_region") { + hasChanged = true + update.FocusBillingReportBucketRegion = d.Get("focus_billing_report_bucket_region").(string) + } + + if d.HasChange("focus_billing_report_name") { + hasChanged = true + update.FocusBillingReportName = d.Get("focus_billing_report_name").(string) + } + + if d.HasChange("focus_billing_report_prefix") { + hasChanged = true + update.FocusBillingReportPrefix = d.Get("focus_billing_report_prefix").(string) + } + + if d.HasChange("focus_bucket_access_role") { + hasChanged = true + update.FocusBucketAccessRole = d.Get("focus_bucket_access_role").(string) + } + + // Authentication fields + if d.HasChange("key_id") { + hasChanged = true + update.KeyID = d.Get("key_id").(string) + } + + if d.HasChange("key_secret") { + hasChanged = true + update.KeySecret = d.Get("key_secret").(string) + } + + // Other fields + if d.HasChange("linked_role") { + hasChanged = true + update.LinkedRole = d.Get("linked_role").(string) + } + + if d.HasChange("detailed_billing_bucket") { + hasChanged = true + update.MRBucket = d.Get("detailed_billing_bucket").(string) + } + + if hasChanged { + // Add warning about using private API + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "Using Private API Endpoint", + Detail: "This resource uses a private API endpoint (/v1/payer) for updates. This endpoint may change without notice and should be used at your own risk.", + }) + + client := m.(*hc.Client) + ID := d.Id() + billingSourceID, err := strconv.Atoi(ID) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to parse billing source ID", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + return diags + } + + // First, get the current billing source to populate required fields + // Use the list endpoint since direct ID endpoint doesn't exist + resp := new(hc.BillingSourceListResponse) + err = client.GET("/v4/billing-source", resp) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to list billing sources", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + return diags + } + + // Find the billing source with matching ID + var currentBillingSource hc.BillingSource + found := false + for _, source := range resp.Data.Items { + if int(source.ID) == billingSourceID { + currentBillingSource = source + found = true + break + } + } + + if !found { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Billing source not found", + Detail: fmt.Sprintf("Billing source with ID %d not found", billingSourceID), + }) + return diags + } + + if currentBillingSource.AWSPayer == nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Billing source is not an AWS billing source", + Detail: fmt.Sprintf("Billing source ID %v is not configured as an AWS billing source", billingSourceID), + }) + return diags + } + + // Extract AWS payer data from interface{} + awsPayerInterface := *currentBillingSource.AWSPayer + awsPayerMap, ok := awsPayerInterface.(map[string]interface{}) + if !ok { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to parse AWS payer data", + Detail: fmt.Sprintf("AWS payer data is not in expected format for billing source ID %v", billingSourceID), + }) + return diags + } + + // Extract the payer ID from the map + payerID := 0 + if id, ok := awsPayerMap["id"]; ok { + if idFloat, ok := id.(float64); ok { + payerID = int(idFloat) + } else if idInt, ok := id.(int); ok { + payerID = idInt + } + } + + // Build update request using v1 API structure + v1Update := hc.AWSBillingSourceV1Update{ + ID: billingSourceID, + AccountTypeID: 1, // AWS Commercial + UseFocusReports: d.Get("billing_report_type").(string) == "focus", + UseProprietaryReports: d.Get("billing_report_type").(string) != "focus", + AccountCreation: d.Get("account_creation").(bool), + SkipValidation: false, + AWSPayer: hc.AWSBillingSourceV1Payer{ + ID: payerID, + Name: d.Get("name").(string), + AccountNumber: d.Get("aws_account_number").(string), + KeyID: d.Get("key_id").(string), + KeySecret: d.Get("key_secret").(string), + BillingRegion: d.Get("billing_region").(string), + BillingReportPrefix: d.Get("cur_prefix").(string), + BillingReportBucket: d.Get("cur_bucket").(string), + BillingReportBucketRegion: d.Get("cur_bucket_region").(string), + BillingReportName: d.Get("cur_name").(string), + BillingBucketAccountNumber: d.Get("billing_bucket_account_number").(string), + BucketAccessRole: d.Get("bucket_access_role").(string), + DetailedBillingBucket: d.Get("detailed_billing_bucket").(string), + FocusBillingBucketAccountNumber: d.Get("focus_billing_bucket_account_number").(string), + FocusBucketAccessRole: d.Get("focus_bucket_access_role").(string), + FocusBillingReportBucketRegion: d.Get("focus_billing_report_bucket_region").(string), + FocusBillingReportBucket: d.Get("focus_billing_report_bucket").(string), + FocusBillingReportPrefix: d.Get("focus_billing_report_prefix").(string), + FocusBillingReportName: d.Get("focus_billing_report_name").(string), + LinkedRole: d.Get("linked_role").(string), + AutoEnrollSupport: false, + SupportType: nil, + }, + } + + // Convert billing_start_date from YYYY-MM to YYYYMM format + if billingStartDate := d.Get("billing_start_date").(string); billingStartDate != "" { + // Convert "2024-07" to 202407 + if len(billingStartDate) == 7 { + dateStr := billingStartDate[:4] + billingStartDate[5:] + if dateInt, err := strconv.Atoi(dateStr); err == nil { + v1Update.AWSPayer.BillingStartDate = dateInt + } + } + } + + // Get the current billing_report_type_id from the existing data and keep it + // Don't change the billing_report_type_id as it may not match what's in the database + currentBillingReportTypeID := 1 // default to CUR + if reportTypeID, ok := awsPayerMap["billing_report_type_id"]; ok { + if reportTypeIDFloat, ok := reportTypeID.(float64); ok { + currentBillingReportTypeID = int(reportTypeIDFloat) + } else if reportTypeIDInt, ok := reportTypeID.(int); ok { + currentBillingReportTypeID = reportTypeIDInt + } + } + + // Keep the existing billing_report_type_id to avoid foreign key constraint errors + v1Update.AWSPayer.BillingReportTypeID = currentBillingReportTypeID + + // Send the PUT request to the v1 API + err = client.PUT(fmt.Sprintf("/v1/payer/%d", billingSourceID), v1Update) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to update AWS billing source", + Detail: fmt.Sprintf("Error: %v\nBilling Source ID: %v", err.Error(), billingSourceID), + }) + return diags + } + } + + return resourceBillingSourceAwsRead(ctx, d, m) +} + +func resourceBillingSourceAwsDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + client := m.(*hc.Client) + + ID := d.Id() + billingSourceID, err := strconv.Atoi(ID) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to parse billing source ID", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + return diags + } + + err = client.DELETE(fmt.Sprintf("/v3/billing-source/%d", billingSourceID), nil) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to delete AWS billing source", + Detail: fmt.Sprintf("Error: %v\nItem: %v", err.Error(), ID), + }) + return diags + } + + // d.SetId("") is automatically called assuming delete returns no errors, but + // it is added here for explicitness. + d.SetId("") + + return diags +} + +func validateBillingSourceAwsFields(diff *schema.ResourceDiff) error { + billingReportType := diff.Get("billing_report_type").(string) + + // Validate CUR required fields + if billingReportType == "cur" { + requiredCurFields := []string{ + "cur_bucket", + "cur_bucket_region", + "cur_name", + "cur_prefix", + } + + for _, field := range requiredCurFields { + if _, ok := diff.GetOk(field); !ok { + return fmt.Errorf("%s is required when billing_report_type is 'cur'", field) + } + } + } + + // Validate FOCUS required fields + if billingReportType == "focus" { + requiredFocusFields := []string{ + "focus_billing_report_bucket", + "focus_billing_report_bucket_region", + "focus_billing_report_name", + "focus_billing_report_prefix", + } + + for _, field := range requiredFocusFields { + if _, ok := diff.GetOk(field); !ok { + return fmt.Errorf("%s is required when billing_report_type is 'focus'", field) + } + } + } + + // Validate DBRRT required fields + if billingReportType == "dbrrt" { + if _, ok := diff.GetOk("detailed_billing_bucket"); !ok { + return fmt.Errorf("detailed_billing_bucket is required when billing_report_type is 'dbrrt'") + } + } + + return nil +} diff --git a/kion/resource_billing_source_gcp.go b/kion/resource_billing_source_gcp.go new file mode 100644 index 00000000..462cad1e --- /dev/null +++ b/kion/resource_billing_source_gcp.go @@ -0,0 +1,498 @@ +package kion + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strconv" + "time" + + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + hc "github.com/kionsoftware/terraform-provider-kion/kion/internal/kionclient" +) + +func resourceBillingSourceGcp() *schema.Resource { + return &schema.Resource{ + Description: "Creates and manages a GCP (Google Cloud Platform) billing source in Kion.\n\n" + + "GCP billing sources are used to import billing data from Google Cloud Platform projects " + + "into Kion for cost management and reporting purposes. The billing data is exported from " + + "BigQuery where Google Cloud exports billing information.\n\n" + + "**WARNING**: Updates to this resource use a private API endpoint that may change without notice. Use at your own risk.", + CreateContext: resourceBillingSourceGcpCreate, + ReadContext: resourceBillingSourceGcpRead, + UpdateContext: resourceBillingSourceGcpUpdate, + DeleteContext: resourceBillingSourceGcpDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + CustomizeDiff: func(ctx context.Context, diff *schema.ResourceDiff, meta interface{}) error { + return validateBillingSourceGcpFields(diff) + }, + Schema: map[string]*schema.Schema{ + // Required fields + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the GCP billing source.", + }, + "service_account_id": { + Type: schema.TypeInt, + Required: true, + Description: "The ID of the GCP service account used for authentication.", + }, + "gcp_id": { + Type: schema.TypeString, + Required: true, + Description: "The GCP ID of the billing account (e.g., '012345-678901-ABCDEF').", + }, + "billing_start_date": { + Type: schema.TypeString, + Required: true, + Description: "The start date for billing data collection in YYYY-MM format.", + ValidateFunc: validation.StringMatch( + regexp.MustCompile(`^\d{4}-(0[1-9]|1[0-2])$`), + "must be in YYYY-MM format", + ), + }, + "big_query_export": { + Type: schema.TypeList, + Required: true, + MaxItems: 1, + Description: "BigQuery export configuration for billing data.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "gcp_project_id": { + Type: schema.TypeString, + Required: true, + Description: "The ID of the GCP project where the BigQuery dataset lives.", + }, + "dataset_name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the BigQuery dataset where the export lives.", + }, + "table_name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the BigQuery table where the export lives.", + }, + "table_format": { + Type: schema.TypeString, + Optional: true, + Default: "auto", + Description: "The format of the BigQuery table where the export lives. One of 'auto', 'standard' or 'detailed'.", + ValidateFunc: validation.StringInSlice([]string{"auto", "standard", "detailed"}, false), + }, + "focus_view_name": { + Type: schema.TypeString, + Optional: true, + Description: "The name of the FOCUS view in BigQuery.", + }, + }, + }, + }, + + // Optional fields + "account_type_id": { + Type: schema.TypeInt, + Optional: true, + Default: 15, + Description: "The account type ID for the GCP billing source. Defaults to 15 (Google Cloud).", + ValidateFunc: validation.IntInSlice([]int{15}), + }, + "is_reseller": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Denotes if the billing account is that of a Parent Reseller Billing Account.", + }, + "use_focus": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Use GCP FOCUS view for billing data.", + }, + "use_proprietary": { + Type: schema.TypeBool, + Optional: true, + Default: true, + Description: "Use the GCP Proprietary Billing Table.", + }, + }, + } +} + +func resourceBillingSourceGcpCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*hc.Client) + + // Build BigQuery export configuration + bigQueryExport := hc.GCPBigQueryExport{} + if v, ok := d.GetOk("big_query_export"); ok { + bqList := v.([]interface{}) + if len(bqList) > 0 { + bq := bqList[0].(map[string]interface{}) + bigQueryExport.GCPProjectID = bq["gcp_project_id"].(string) + bigQueryExport.DatasetName = bq["dataset_name"].(string) + bigQueryExport.TableName = bq["table_name"].(string) + if tableFormat, ok := bq["table_format"].(string); ok { + bigQueryExport.TableFormat = tableFormat + } + if focusViewName, ok := bq["focus_view_name"].(string); ok { + bigQueryExport.FOCUSViewName = focusViewName + } + } + } + + // Build the request payload + payload := hc.GCPBillingSourceCreate{ + AccountTypeID: uint(d.Get("account_type_id").(int)), + GCPBillingAccountCreate: hc.GCPBillingAccountWithStart{ + ServiceAccountID: uint(d.Get("service_account_id").(int)), + Name: d.Get("name").(string), + GCPID: d.Get("gcp_id").(string), + BillingStartDate: d.Get("billing_start_date").(string), + BigQueryExport: bigQueryExport, + IsReseller: d.Get("is_reseller").(bool), + UseFOCUS: d.Get("use_focus").(bool), + UseProprietary: d.Get("use_proprietary").(bool), + }, + } + + // Create the billing source + resp, err := c.POST("/v3/billing-source/gcp", payload) + if err != nil { + return diag.FromErr(err) + } + + // The POST returns a Creation object with the ID + d.SetId(strconv.Itoa(resp.RecordID)) + + // Wait for the billing source to be available + err = retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { + _, err := readGCPBillingSource(c, d.Id()) + if err != nil { + return retry.RetryableError(fmt.Errorf("billing source not yet available: %v", err)) + } + return nil + }) + + if err != nil { + return diag.FromErr(err) + } + + // Read the created resource + return resourceBillingSourceGcpRead(ctx, d, m) +} + +func resourceBillingSourceGcpRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*hc.Client) + + var diags diag.Diagnostics + + billingSource, err := readGCPBillingSource(c, d.Id()) + if err != nil { + if resErr, ok := err.(*hc.RequestError); ok && resErr.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "GCP billing source not found, removing from state", map[string]interface{}{ + "id": d.Id(), + }) + d.SetId("") + return diags + } + return diag.FromErr(err) + } + + // Update the resource data using SafeSet helper + diags = append(diags, hc.SafeSet(d, "name", billingSource.Name, "Unable to set name")...) + diags = append(diags, hc.SafeSet(d, "service_account_id", billingSource.ServiceAccountID, "Unable to set service_account_id")...) + diags = append(diags, hc.SafeSet(d, "gcp_id", billingSource.GCPID, "Unable to set gcp_id")...) + + if len(diags) > 0 { + return diags + } + // BillingStartDate is not returned in GET response, so we preserve it from state + if billingSource.BillingStartDate == "" { + // Keep the existing value from state + billingSource.BillingStartDate = d.Get("billing_start_date").(string) + } + diags = append(diags, hc.SafeSet(d, "billing_start_date", billingSource.BillingStartDate, "Unable to set billing_start_date")...) + + // Set BigQuery export configuration + bigQueryExport := []map[string]interface{}{ + { + "gcp_project_id": billingSource.BigQueryExport.GCPProjectID, + "dataset_name": billingSource.BigQueryExport.DatasetName, + "table_name": billingSource.BigQueryExport.TableName, + "table_format": billingSource.BigQueryExport.TableFormat, + "focus_view_name": billingSource.BigQueryExport.FOCUSViewName, + }, + } + diags = append(diags, hc.SafeSet(d, "big_query_export", bigQueryExport, "Unable to set big_query_export")...) + + // AccountTypeID is not returned in GET, so we preserve it from state + // Only set it if it's not already set (e.g., during import) + if billingSource.AccountTypeID != 0 { + diags = append(diags, hc.SafeSet(d, "account_type_id", billingSource.AccountTypeID, "Unable to set account_type_id")...) + } + + diags = append(diags, hc.SafeSet(d, "is_reseller", billingSource.IsReseller, "Unable to set is_reseller")...) + diags = append(diags, hc.SafeSet(d, "use_focus", billingSource.UseFOCUSReports, "Unable to set use_focus")...) + diags = append(diags, hc.SafeSet(d, "use_proprietary", billingSource.UseProprietaryReports, "Unable to set use_proprietary")...) + + return diags +} + +func resourceBillingSourceGcpUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + client := m.(*hc.Client) + + // Add warning about using private API + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "Using Private API Endpoint", + Detail: "This resource uses a private API endpoint for updates. This endpoint may change without notice and should be used at your own risk.", + }) + + ID := d.Id() + billingSourceID, err := strconv.Atoi(ID) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to parse billing source ID", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + return diags + } + + // First, get the current billing source to populate required fields + // Use the list endpoint since direct ID endpoint doesn't exist + resp := new(hc.BillingSourceListResponse) + err = client.GET("/v4/billing-source", resp) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to list billing sources", + Detail: fmt.Sprintf("Error: %v", err.Error()), + }) + return diags + } + + // Find the billing source with matching ID + var currentBillingSource hc.BillingSource + found := false + for _, source := range resp.Data.Items { + if int(source.ID) == billingSourceID { + currentBillingSource = source + found = true + break + } + } + + if !found { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Billing source not found", + Detail: fmt.Sprintf("Billing source with ID %d not found", billingSourceID), + }) + return diags + } + + if currentBillingSource.GCPPayer == nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Billing source is not a GCP billing source", + Detail: fmt.Sprintf("Billing source ID %v is not configured as a GCP billing source", billingSourceID), + }) + return diags + } + + // Handle BigQuery export settings - extract from nested structure + bigQueryExport := hc.GCPBigQueryExportV1{ + TableFormat: "auto", // default + FOCUSViewName: "", // default + } + + // Get BigQuery export settings from the schema + if bigQueryExportList, ok := d.GetOk("big_query_export"); ok { + bigQueryExportData := bigQueryExportList.([]interface{})[0].(map[string]interface{}) + bigQueryExport.GCPProjectID = bigQueryExportData["gcp_project_id"].(string) + bigQueryExport.DatasetName = bigQueryExportData["dataset_name"].(string) + + if tableFormat, ok := bigQueryExportData["table_format"]; ok { + bigQueryExport.TableFormat = tableFormat.(string) + } + + if focusViewName, ok := bigQueryExportData["focus_view_name"]; ok { + bigQueryExport.FOCUSViewName = focusViewName.(string) + } + + // Handle table_name as optional field + if tableName, ok := bigQueryExportData["table_name"]; ok && tableName.(string) != "" { + tableNameStr := tableName.(string) + bigQueryExport.TableName = &tableNameStr + } + } + + // Convert billing_start_date from YYYY-MM to YYYYMM format + billingStartDate := 0 + if dateStr := d.Get("billing_start_date").(string); dateStr != "" { + // Convert "2022-01" to 202201 + if len(dateStr) == 7 { + dateStr = dateStr[:4] + dateStr[5:] + if dateInt, err := strconv.Atoi(dateStr); err == nil { + billingStartDate = dateInt + } + } + } + + // Build update request using v1 API structure + v1Update := hc.GCPBillingSourceV1Update{ + ID: billingSourceID, + AccountTypeID: 15, // GCP Project account type + UseFocusReports: d.Get("use_focus").(bool), + UseProprietaryReports: d.Get("use_proprietary").(bool), + AccountCreation: false, // GCP billing sources don't support account creation + SkipValidation: true, // Based on your example request + GCPBillingAccountUpdate: hc.GCPBillingAccountV1Update{ + Name: d.Get("name").(string), + ServiceAccountID: d.Get("service_account_id").(int), + GCPID: d.Get("gcp_id").(string), + BillingStartDate: billingStartDate, + IsReseller: d.Get("is_reseller").(bool), + BigQueryExport: bigQueryExport, + }, + } + + // Send the PUT request to the v1 API + err = client.PUT(fmt.Sprintf("/v1/billing-source/%d", billingSourceID), v1Update) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Unable to update GCP billing source", + Detail: fmt.Sprintf("Error: %v\nBilling Source ID: %v", err.Error(), billingSourceID), + }) + return diags + } + + // Read the updated resource + return resourceBillingSourceGcpRead(ctx, d, m) +} + +func resourceBillingSourceGcpDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*hc.Client) + + var diags diag.Diagnostics + + // Delete the billing source + err := c.DELETE(fmt.Sprintf("/v3/billing-source/%s", d.Id()), nil) + if err != nil { + if resErr, ok := err.(*hc.RequestError); ok && resErr.StatusCode == http.StatusNotFound { + // If already deleted, we can consider this successful + tflog.Info(ctx, "GCP billing source already deleted", map[string]interface{}{ + "id": d.Id(), + }) + } else { + return diag.FromErr(err) + } + } + + // Wait for deletion to complete + deleteStateConf := &retry.StateChangeConf{ + Pending: []string{"200"}, + Target: []string{"404"}, + Refresh: func() (interface{}, string, error) { + resp, err := readGCPBillingSource(c, d.Id()) + if err != nil { + if resErr, ok := err.(*hc.RequestError); ok && resErr.StatusCode == http.StatusNotFound { + return resp, "404", nil + } + return nil, "", err + } + return resp, "200", nil + }, + Timeout: 2 * time.Minute, + Delay: 10 * time.Second, + MinTimeout: 3 * time.Second, + ContinuousTargetOccurence: 2, + } + + _, err = deleteStateConf.WaitForStateContext(ctx) + + if err != nil { + return diag.FromErr(err) + } + + d.SetId("") + return diags +} + +// Helper function to read a GCP billing source +func readGCPBillingSource(c *hc.Client, id string) (*hc.GCPBillingSource, error) { + // Get billing source by ID using the list endpoint since direct ID endpoint is inconsistent + billingSourceID, err := strconv.Atoi(id) + if err != nil { + return nil, fmt.Errorf("unable to parse billing source ID: %v", err) + } + + listResp := new(hc.BillingSourceListResponse) + err = c.GET("/v4/billing-source", listResp) + if err != nil { + return nil, err + } + + // Find the billing source with matching ID + var resp hc.BillingSource + found := false + for _, bs := range listResp.Data.Items { + if bs.ID == uint(billingSourceID) { + resp = bs + found = true + break + } + } + + if !found { + return nil, fmt.Errorf("billing source %s not found", id) + } + + // Check if this is a GCP billing source + if resp.GCPPayer == nil { + return nil, fmt.Errorf("billing source %s is not a GCP billing source", id) + } + + // Convert GCPPayer to GCPBillingSource + // Note: Some fields like AccountTypeID and BillingStartDate are not returned in the GET response + // These fields are preserved from the Terraform state + return &hc.GCPBillingSource{ + ID: resp.ID, + Name: resp.GCPPayer.GCPBillingAccount.Name, + ServiceAccountID: resp.GCPPayer.GCPBillingAccount.ServiceAccountID, + GCPID: resp.GCPPayer.GCPBillingAccount.GCPID, + BillingStartDate: "", // This will be preserved from state in the Read function + BigQueryExport: resp.GCPPayer.GCPBillingAccount.BigQueryExport, + IsReseller: resp.GCPPayer.GCPBillingAccount.IsReseller, + UseFOCUSReports: resp.UseFocusReports, + UseProprietaryReports: resp.UseProprietaryReports, + }, nil +} + +func validateBillingSourceGcpFields(diff *schema.ResourceDiff) error { + // Validate that focus_view_name is provided when use_focus is true + if diff.Get("use_focus").(bool) { + if v, ok := diff.GetOk("big_query_export"); ok { + bqList := v.([]interface{}) + if len(bqList) > 0 { + bq := bqList[0].(map[string]interface{}) + if focusViewName, ok := bq["focus_view_name"].(string); !ok || focusViewName == "" { + return fmt.Errorf("focus_view_name is required in big_query_export when use_focus is true") + } + } + } + } + + return nil +} diff --git a/kion/resource_billing_source_oci.go b/kion/resource_billing_source_oci.go new file mode 100644 index 00000000..a76721e6 --- /dev/null +++ b/kion/resource_billing_source_oci.go @@ -0,0 +1,389 @@ +package kion + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strconv" + "time" + + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + hc "github.com/kionsoftware/terraform-provider-kion/kion/internal/kionclient" +) + +func resourceBillingSourceOci() *schema.Resource { + return &schema.Resource{ + Description: "Creates and manages an OCI (Oracle Cloud Infrastructure) billing source in Kion.\n\n" + + "OCI billing sources are used to import billing data from Oracle Cloud Infrastructure tenancies " + + "into Kion for cost management and reporting purposes.", + CreateContext: resourceBillingSourceOciCreate, + ReadContext: resourceBillingSourceOciRead, + UpdateContext: resourceBillingSourceOciUpdate, + DeleteContext: resourceBillingSourceOciDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + CustomizeDiff: func(ctx context.Context, diff *schema.ResourceDiff, meta interface{}) error { + return validateBillingSourceOciFields(diff) + }, + Schema: map[string]*schema.Schema{ + // Required fields + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the OCI billing source.", + }, + "billing_start_date": { + Type: schema.TypeString, + Required: true, + Description: "The start date for billing data collection in YYYY-MM format.", + ValidateFunc: validation.StringMatch( + regexp.MustCompile(`^\d{4}-(0[1-9]|1[0-2])$`), + "must be in YYYY-MM format", + ), + }, + + // Optional fields + "account_type_id": { + Type: schema.TypeInt, + Optional: true, + Default: 26, + Description: "The account type ID for the OCI billing source. Valid values are: 26 (OCI Commercial), 27 (OCI Government), 28 (OCI Federal). Defaults to 26.", + ValidateFunc: validation.IntInSlice([]int{26, 27, 28}), + }, + "tenancy_ocid": { + Type: schema.TypeString, + Optional: true, + Description: "The OCID of the OCI tenancy.", + }, + "user_ocid": { + Type: schema.TypeString, + Optional: true, + Description: "The OCID of the OCI user for API access.", + }, + "fingerprint": { + Type: schema.TypeString, + Optional: true, + Description: "The fingerprint of the API key for authentication.", + }, + "private_key": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + Description: "The private key for API authentication.", + }, + "region": { + Type: schema.TypeString, + Optional: true, + Description: "The default OCI region for API access.", + }, + "is_parent_tenancy": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Indicates whether this billing source is a parent OCI tenancy.", + }, + "use_focus_reports": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "If true, Kion will use FOCUS reports for this billing source.", + }, + "use_proprietary_reports": { + Type: schema.TypeBool, + Optional: true, + Default: true, + Description: "If true, Kion will use proprietary Oracle Cost Reports for this billing source.", + }, + "skip_validation": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "When true, will skip validating the connection to the billing source during creation or update.", + }, + }, + } +} + +func resourceBillingSourceOciCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*hc.Client) + + // Build the request payload + payload := hc.OCIBillingSourceCreate{ + Name: d.Get("name").(string), + BillingStartDate: d.Get("billing_start_date").(string), + AccountTypeID: uint(d.Get("account_type_id").(int)), + IsParentTenancy: d.Get("is_parent_tenancy").(bool), + UseFOCUSReports: d.Get("use_focus_reports").(bool), + UseProprietaryReports: d.Get("use_proprietary_reports").(bool), + SkipValidation: d.Get("skip_validation").(bool), + } + + // Add optional fields if provided + if v, ok := d.GetOk("tenancy_ocid"); ok { + payload.TenancyOCID = v.(string) + } + if v, ok := d.GetOk("user_ocid"); ok { + payload.UserOCID = v.(string) + } + if v, ok := d.GetOk("fingerprint"); ok { + payload.Fingerprint = v.(string) + } + if v, ok := d.GetOk("private_key"); ok { + payload.PrivateKey = v.(string) + } + if v, ok := d.GetOk("region"); ok { + payload.Region = v.(string) + } + + // Create the billing source + resp, err := c.POST("/v3/billing-source/oci", payload) + if err != nil { + return diag.FromErr(err) + } + + // The POST returns a Creation object with the ID + d.SetId(strconv.Itoa(resp.RecordID)) + + // Wait for the billing source to be available + err = retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { + _, err := readOCIBillingSource(c, d.Id()) + if err != nil { + return retry.RetryableError(fmt.Errorf("billing source not yet available: %v", err)) + } + return nil + }) + + if err != nil { + return diag.FromErr(err) + } + + // Read the created resource + return resourceBillingSourceOciRead(ctx, d, m) +} + +func resourceBillingSourceOciRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*hc.Client) + + var diags diag.Diagnostics + + billingSource, err := readOCIBillingSource(c, d.Id()) + if err != nil { + if resErr, ok := err.(*hc.RequestError); ok && resErr.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "OCI billing source not found, removing from state", map[string]interface{}{ + "id": d.Id(), + }) + d.SetId("") + return diags + } + return diag.FromErr(err) + } + + // Update the resource data using SafeSet helper + diags = append(diags, hc.SafeSet(d, "name", billingSource.Name, "Unable to set name")...) + + // AccountTypeID is not returned in GET, so we preserve it from state + // Only set it if it's not already set (e.g., during import) + if billingSource.AccountTypeID != 0 { + diags = append(diags, hc.SafeSet(d, "account_type_id", billingSource.AccountTypeID, "Unable to set account_type_id")...) + } + + diags = append(diags, hc.SafeSet(d, "billing_start_date", billingSource.BillingStartDate, "Unable to set billing_start_date")...) + diags = append(diags, hc.SafeSet(d, "tenancy_ocid", billingSource.TenancyOCID, "Unable to set tenancy_ocid")...) + diags = append(diags, hc.SafeSet(d, "user_ocid", billingSource.UserOCID, "Unable to set user_ocid")...) + diags = append(diags, hc.SafeSet(d, "fingerprint", billingSource.Fingerprint, "Unable to set fingerprint")...) + + // Note: We don't set private_key back as it's sensitive and not returned by the API + diags = append(diags, hc.SafeSet(d, "region", billingSource.Region, "Unable to set region")...) + + // IsParentTenancy is not returned in GET, so we preserve it from state + if billingSource.IsParentTenancy { + diags = append(diags, hc.SafeSet(d, "is_parent_tenancy", billingSource.IsParentTenancy, "Unable to set is_parent_tenancy")...) + } + + diags = append(diags, hc.SafeSet(d, "use_focus_reports", billingSource.UseFOCUSReports, "Unable to set use_focus_reports")...) + diags = append(diags, hc.SafeSet(d, "use_proprietary_reports", billingSource.UseProprietaryReports, "Unable to set use_proprietary_reports")...) + + return diags +} + +func resourceBillingSourceOciUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*hc.Client) + + if d.HasChanges("name", "account_type_id", "billing_start_date", "tenancy_ocid", "user_ocid", + "fingerprint", "private_key", "region", "is_parent_tenancy", "use_focus_reports", "use_proprietary_reports") { + + // Build the update payload + payload := hc.OCIBillingSourceUpdate{ + ID: uint(mustAtoi(d.Id())), + Name: d.Get("name").(string), + AccountTypeID: uint(d.Get("account_type_id").(int)), + BillingStartDate: d.Get("billing_start_date").(string), + IsParentTenancy: d.Get("is_parent_tenancy").(bool), + UseFOCUSReports: d.Get("use_focus_reports").(bool), + UseProprietaryReports: d.Get("use_proprietary_reports").(bool), + SkipValidation: d.Get("skip_validation").(bool), + } + + // Add optional fields if provided + if v, ok := d.GetOk("tenancy_ocid"); ok { + payload.TenancyOCID = v.(string) + } + if v, ok := d.GetOk("user_ocid"); ok { + payload.UserOCID = v.(string) + } + if v, ok := d.GetOk("fingerprint"); ok { + payload.Fingerprint = v.(string) + } + if v, ok := d.GetOk("private_key"); ok { + payload.PrivateKey = v.(string) + } + if v, ok := d.GetOk("region"); ok { + payload.Region = v.(string) + } + + // Update the billing source + err := c.PATCH(fmt.Sprintf("/v3/billing-source/oci/%s", d.Id()), payload) + if err != nil { + return diag.FromErr(err) + } + } + + // Read the updated resource + return resourceBillingSourceOciRead(ctx, d, m) +} + +func resourceBillingSourceOciDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*hc.Client) + + var diags diag.Diagnostics + + // Delete the billing source + err := c.DELETE(fmt.Sprintf("/v3/billing-source/%s", d.Id()), nil) + if err != nil { + if resErr, ok := err.(*hc.RequestError); ok && resErr.StatusCode == http.StatusNotFound { + // If already deleted, we can consider this successful + tflog.Info(ctx, "OCI billing source already deleted", map[string]interface{}{ + "id": d.Id(), + }) + } else { + return diag.FromErr(err) + } + } + + // Wait for deletion to complete + deleteStateConf := &retry.StateChangeConf{ + Pending: []string{"200"}, + Target: []string{"404"}, + Refresh: func() (interface{}, string, error) { + resp, err := readOCIBillingSource(c, d.Id()) + if err != nil { + if resErr, ok := err.(*hc.RequestError); ok && resErr.StatusCode == http.StatusNotFound { + return resp, "404", nil + } + return nil, "", err + } + return resp, "200", nil + }, + Timeout: 2 * time.Minute, + Delay: 10 * time.Second, + MinTimeout: 3 * time.Second, + ContinuousTargetOccurence: 2, + } + + _, err = deleteStateConf.WaitForStateContext(ctx) + + if err != nil { + return diag.FromErr(err) + } + + d.SetId("") + return diags +} + +// Helper function to read an OCI billing source +func readOCIBillingSource(c *hc.Client, id string) (*hc.OCIBillingSource, error) { + // Get billing source by ID using the list endpoint since direct ID endpoint is inconsistent + billingSourceID, err := strconv.Atoi(id) + if err != nil { + return nil, fmt.Errorf("unable to parse billing source ID: %v", err) + } + + listResp := new(hc.BillingSourceListResponse) + err = c.GET("/v4/billing-source", listResp) + if err != nil { + return nil, err + } + + // Find the billing source with matching ID + var resp hc.BillingSource + found := false + for _, bs := range listResp.Data.Items { + if bs.ID == uint(billingSourceID) { + resp = bs + found = true + break + } + } + + if !found { + return nil, fmt.Errorf("billing source %s not found", id) + } + + // Check if this is an OCI billing source + if resp.OCIPayer == nil { + return nil, fmt.Errorf("billing source %s is not an OCI billing source", id) + } + + // Convert OCIPayer to OCIBillingSource + // Note: Some fields like AccountTypeID and IsParentTenancy are not returned in the GET response + // These fields are preserved from the Terraform state + return &hc.OCIBillingSource{ + ID: resp.OCIPayer.ID, + Name: resp.OCIPayer.Name, + BillingStartDate: resp.OCIPayer.BillingStartDate, + TenancyOCID: resp.OCIPayer.TenancyOCID, + UserOCID: resp.OCIPayer.UserOCID, + Fingerprint: resp.OCIPayer.Fingerprint, + Region: resp.OCIPayer.Region, + UseFOCUSReports: resp.UseFocusReports, + UseProprietaryReports: resp.UseProprietaryReports, + }, nil +} + +// Helper function to convert string to int +func mustAtoi(s string) int { + i, err := strconv.Atoi(s) + if err != nil { + panic(err) + } + return i +} + +func validateBillingSourceOciFields(diff *schema.ResourceDiff) error { + // Validate that if any API authentication field is provided, all should be provided + apiAuthFields := []string{"user_ocid", "fingerprint", "private_key"} + providedAuthFields := 0 + for _, field := range apiAuthFields { + if _, ok := diff.GetOk(field); ok { + providedAuthFields++ + } + } + + if providedAuthFields > 0 && providedAuthFields < len(apiAuthFields) { + missingFields := []string{} + for _, field := range apiAuthFields { + if _, ok := diff.GetOk(field); !ok { + missingFields = append(missingFields, field) + } + } + return fmt.Errorf("when providing OCI API authentication, all of the following fields must be provided: user_ocid, fingerprint, private_key. Missing fields: %v", missingFields) + } + + return nil +} diff --git a/kion/resource_ou_cloud_access_role.go b/kion/resource_ou_cloud_access_role.go index b9aca672..d054a5fe 100644 --- a/kion/resource_ou_cloud_access_role.go +++ b/kion/resource_ou_cloud_access_role.go @@ -54,7 +54,7 @@ func resourceOUCloudAccessRole() *schema.Resource { }, "aws_iam_role_name": { Type: schema.TypeString, - Required: true, + Optional: true, ForceNew: true, // Not allowed to be changed, forces new item if changed. }, "azure_role_definitions": { @@ -139,7 +139,7 @@ func resourceOUCloudAccessRoleCreate(ctx context.Context, d *schema.ResourceData AwsIamPath: d.Get("aws_iam_path").(string), AwsIamPermissionsBoundary: hc.FlattenIntPointer(d, "aws_iam_permissions_boundary"), AwsIamPolicies: hc.FlattenGenericIDPointer(d, "aws_iam_policies"), - AwsIamRoleName: d.Get("aws_iam_role_name").(string), + AwsIamRoleName: hc.FlattenStringPointer(d, "aws_iam_role_name"), AzureRoleDefinitions: hc.FlattenGenericIDPointer(d, "azure_role_definitions"), GCPIamRoles: hc.FlattenGenericIDPointer(d, "gcp_iam_roles"), LongTermAccessKeys: d.Get("long_term_access_keys").(bool), @@ -199,7 +199,12 @@ func resourceOUCloudAccessRoleRead(ctx context.Context, d *schema.ResourceData, data := map[string]interface{}{ "aws_iam_path": item.OUCloudAccessRole.AwsIamPath, - "aws_iam_role_name": item.OUCloudAccessRole.AwsIamRoleName, + "aws_iam_role_name": func() string { + if item.OUCloudAccessRole.AwsIamRoleName != nil { + return *item.OUCloudAccessRole.AwsIamRoleName + } + return "" + }(), "aws_iam_permissions_boundary": hc.InflateSingleObjectWithID(item.AwsIamPermissionsBoundary), "long_term_access_keys": item.OUCloudAccessRole.LongTermAccessKeys, "name": item.OUCloudAccessRole.Name, diff --git a/kion/resource_project.go b/kion/resource_project.go index 8338e8ce..57838c5a 100644 --- a/kion/resource_project.go +++ b/kion/resource_project.go @@ -41,7 +41,7 @@ func resourceProject() *schema.Resource { declaredAmount := budgetMap["amount"].(float64) if !hc.AlmostEqual(monthlyTotal, declaredAmount, 0.01) { return fmt.Errorf( - "Budget #%d: The sum of monthly budget data amounts (%.2f) does not match the declared budget amount (%.2f). Please ensure they match.", + "budget #%d: the sum of monthly budget data amounts (%.2f) does not match the declared budget amount (%.2f)", i+1, monthlyTotal, declaredAmount, ) } diff --git a/kion/resource_project_cloud_access_role.go b/kion/resource_project_cloud_access_role.go index 9d41a0db..1c62f4a2 100644 --- a/kion/resource_project_cloud_access_role.go +++ b/kion/resource_project_cloud_access_role.go @@ -76,7 +76,7 @@ func resourceProjectCloudAccessRole() *schema.Resource { }, "aws_iam_role_name": { Type: schema.TypeString, - Required: true, + Optional: true, ForceNew: true, // Not allowed to be changed, forces new item if changed. }, "azure_role_definitions": { @@ -167,7 +167,7 @@ func resourceProjectCloudAccessRoleCreate(ctx context.Context, d *schema.Resourc AwsIamPath: d.Get("aws_iam_path").(string), AwsIamPermissionsBoundary: hc.FlattenIntPointer(d, "aws_iam_permissions_boundary"), AwsIamPolicies: hc.FlattenGenericIDPointer(d, "aws_iam_policies"), - AwsIamRoleName: d.Get("aws_iam_role_name").(string), + AwsIamRoleName: hc.FlattenStringPointer(d, "aws_iam_role_name"), AzureRoleDefinitions: hc.FlattenGenericIDPointer(d, "azure_role_definitions"), FutureAccounts: d.Get("future_accounts").(bool), GCPIamRoles: hc.FlattenGenericIDPointer(d, "gcp_iam_roles"), @@ -229,7 +229,12 @@ func resourceProjectCloudAccessRoleRead(ctx context.Context, d *schema.ResourceD data := map[string]interface{}{ "apply_to_all_accounts": item.ProjectCloudAccessRole.ApplyToAllAccounts, "aws_iam_path": item.ProjectCloudAccessRole.AwsIamPath, - "aws_iam_role_name": item.ProjectCloudAccessRole.AwsIamRoleName, + "aws_iam_role_name": func() string { + if item.ProjectCloudAccessRole.AwsIamRoleName != nil { + return *item.ProjectCloudAccessRole.AwsIamRoleName + } + return "" + }(), "aws_iam_permissions_boundary": hc.InflateSingleObjectWithID(item.AwsIamPermissionsBoundary), "long_term_access_keys": item.ProjectCloudAccessRole.LongTermAccessKeys, "name": item.ProjectCloudAccessRole.Name,