athenaintegration.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. package aws
  2. import (
  3. "context"
  4. "fmt"
  5. "strconv"
  6. "strings"
  7. "time"
  8. "github.com/aws/aws-sdk-go-v2/service/athena/types"
  9. "github.com/opencost/opencost/core/pkg/log"
  10. "github.com/opencost/opencost/core/pkg/opencost"
  11. "github.com/opencost/opencost/core/pkg/util/json"
  12. "github.com/opencost/opencost/core/pkg/util/timeutil"
  13. "github.com/opencost/opencost/pkg/cloud"
  14. )
  15. // Resource Tag Columns
  16. const AthenaResourceTagPrefix = "resource_tags_"
  17. const AthenaResourceTagsUserPrefix = "user_"
  18. const AthenaResourceTagsAWSPrefix = "aws_"
  19. const LabelColumnPrefix = AthenaResourceTagPrefix + AthenaResourceTagsUserPrefix
  20. const AWSLabelColumnPrefix = AthenaResourceTagPrefix + AthenaResourceTagsAWSPrefix
  21. const AthenaResourceTagsColumn = "resource_tags"
  22. const AthenaResourceTagsCastToJsonColumn = "CAST(resource_tags AS JSON) as resource_tags"
  23. const AthenaInvoiceEntityNameColumn = "bill_payer_account_name"
  24. const AthenaAccountNameColumn = "line_item_usage_account_name"
  25. // athenaDateLayout is the default AWS date format
  26. const AthenaDateLayout = "2006-01-02 15:04:05.000"
  27. // Cost Columns
  28. const AthenaPricingColumn = "line_item_unblended_cost"
  29. // Amortized Cost Columns
  30. const AthenaRIPricingColumn = "reservation_effective_cost"
  31. const AthenaSPPricingColumn = "savings_plan_savings_plan_effective_cost"
  32. // Net Cost Columns
  33. const AthenaNetPricingColumn = "line_item_net_unblended_cost"
  34. var AthenaNetPricingCoalesce = fmt.Sprintf("COALESCE(%s, %s, 0)", AthenaNetPricingColumn, AthenaPricingColumn)
  35. // Amortized Net Cost Columns
  36. const AthenaNetRIPricingColumn = "reservation_net_effective_cost"
  37. var AthenaNetRIPricingCoalesce = fmt.Sprintf("COALESCE(%s, %s, 0)", AthenaNetRIPricingColumn, AthenaRIPricingColumn)
  38. const AthenaNetSPPricingColumn = "savings_plan_net_savings_plan_effective_cost"
  39. var AthenaNetSPPricingCoalesce = fmt.Sprintf("COALESCE(%s, %s, 0)", AthenaNetSPPricingColumn, AthenaSPPricingColumn)
  40. // athenaDateTruncColumn Aggregates line items from the hourly level to daily. "line_item_usage_start_date" is used because at
  41. // all time values 00:00-23:00 it will truncate to the correct date.
  42. const AthenaDateColumn = "line_item_usage_start_date"
  43. const AthenaDateTruncColumn = "DATE_TRUNC('day'," + AthenaDateColumn + ") as usage_date"
  44. // AthenaBillingEntityColumn distinguishes standard AWS charges ('AWS') from AWS
  45. // Marketplace charges ('AWS Marketplace') on a CUR line item.
  46. const AthenaBillingEntityColumn = "bill_billing_entity"
  47. const AthenaMarketplaceBillingEntity = "AWS Marketplace"
  48. const AthenaWhereDateFmt = `line_item_usage_start_date >= date '%s' AND line_item_usage_start_date < date '%s'`
  49. // AthenaWhereUsageBase filters to usage-driving line item types only. It references no
  50. // optional CUR columns, so it is always safe to use regardless of which columns a given
  51. // CUR export includes.
  52. const AthenaWhereUsageBase = "(line_item_line_item_type = 'Usage' OR line_item_line_item_type = 'DiscountedUsage' OR line_item_line_item_type = 'SavingsPlanCoveredUsage' OR line_item_line_item_type = 'EdpDiscount' OR line_item_line_item_type = 'PrivateRateDiscount')"
  53. // AthenaWhereUsage extends AthenaWhereUsageBase with AWS Marketplace 'Fee' line items
  54. // (flat-rate/subscription charges for third-party SaaS products). Marketplace is scoped
  55. // to bill_billing_entity = 'AWS Marketplace' so this does not also pull in
  56. // non-Marketplace 'Fee' rows, such as Reserved Instance upfront purchases, which are
  57. // outside the scope of this Marketplace-specific fix. CUR 2.0 exports can disable any
  58. // column, including bill_billing_entity, so callers must only use this filter when
  59. // AthenaBillingEntityColumn is confirmed present (see getCloudCost) -- otherwise the
  60. // query will fail with COLUMN_NOT_FOUND and fall back to AthenaWhereUsageBase instead.
  61. var AthenaWhereUsage = fmt.Sprintf(
  62. "(line_item_line_item_type = 'Usage' OR line_item_line_item_type = 'DiscountedUsage' OR line_item_line_item_type = 'SavingsPlanCoveredUsage' OR line_item_line_item_type = 'EdpDiscount' OR line_item_line_item_type = 'PrivateRateDiscount' OR (line_item_line_item_type = 'Fee' AND %s = '%s'))",
  63. AthenaBillingEntityColumn, AthenaMarketplaceBillingEntity,
  64. )
  65. // AthenaQueryIndexes is a struct for holding the context of a query
  66. type AthenaQueryIndexes struct {
  67. Query string
  68. ColumnIndexes map[string]int
  69. TagColumns []string
  70. AWSTagColumns []string
  71. ListCostColumn string
  72. NetCostColumn string
  73. AmortizedNetCostColumn string
  74. AmortizedCostColumn string
  75. IsK8sColumn string
  76. }
  77. type AthenaIntegration struct {
  78. AthenaQuerier
  79. }
  80. // Query Athena for CUR data and build a new CloudCostSetRange containing the info
  81. func (ai *AthenaIntegration) GetCloudCost(start, end time.Time) (*opencost.CloudCostSetRange, error) {
  82. return ai.getCloudCost(start, end, 0)
  83. }
  84. func (ai *AthenaIntegration) RefreshStatus() cloud.ConnectionStatus {
  85. end := time.Now().UTC().Truncate(timeutil.Day)
  86. start := end.Add(-3 * timeutil.Day) // lookback 72 hours
  87. // getCloudCost already sets ConnectionStatus in the event there is no error, so we don't need to handle the positive
  88. // case here
  89. _, err := ai.getCloudCost(start, end, 1)
  90. if err != nil {
  91. log.Errorf("AthenaIntegration: RefreshStatus: error while refreshing status: %s", err.Error())
  92. ai.ConnectionStatus = cloud.FailedConnection
  93. }
  94. return ai.ConnectionStatus
  95. }
  96. func (ai *AthenaIntegration) getCloudCost(start, end time.Time, limit int) (*opencost.CloudCostSetRange, error) {
  97. log.Infof("AthenaIntegration[%s]: GetCloudCost: %s", ai.Key(), opencost.NewWindow(&start, &end).String())
  98. // Query for all column names
  99. allColumns, err := ai.GetColumns()
  100. if err != nil {
  101. return nil, fmt.Errorf("GetCloudCost: error getting Athena columns: %w", err)
  102. }
  103. // List known, hard-coded columns to query
  104. groupByColumns := []string{
  105. AthenaDateTruncColumn,
  106. "line_item_resource_id",
  107. "bill_payer_account_id",
  108. "line_item_usage_account_id",
  109. "line_item_product_code",
  110. "line_item_usage_type",
  111. "product_region_code",
  112. "line_item_availability_zone",
  113. }
  114. // Create query indices
  115. aqi := AthenaQueryIndexes{}
  116. // Add is k8s column
  117. isK8sColumn := ai.GetIsKubernetesColumn(allColumns)
  118. groupByColumns = append(groupByColumns, isK8sColumn)
  119. aqi.IsK8sColumn = isK8sColumn
  120. // Determine which columns are user-defined tags and add those to the list
  121. // of columns to query.
  122. for column := range allColumns {
  123. if strings.HasPrefix(column, LabelColumnPrefix) {
  124. quotedTag := fmt.Sprintf(`"%s"`, column)
  125. groupByColumns = append(groupByColumns, quotedTag)
  126. aqi.TagColumns = append(aqi.TagColumns, quotedTag)
  127. }
  128. if strings.HasPrefix(column, AWSLabelColumnPrefix) {
  129. groupByColumns = append(groupByColumns, column)
  130. aqi.AWSTagColumns = append(aqi.AWSTagColumns, column)
  131. }
  132. }
  133. // CUR 2.0 specific columns, CUR 2.0 has ability to disable any column, so we check for any of these columns before querying
  134. if allColumns[AthenaResourceTagsColumn] {
  135. groupByColumns = append(groupByColumns, AthenaResourceTagsCastToJsonColumn)
  136. }
  137. if allColumns[AthenaAccountNameColumn] {
  138. groupByColumns = append(groupByColumns, AthenaAccountNameColumn)
  139. }
  140. if allColumns[AthenaInvoiceEntityNameColumn] {
  141. groupByColumns = append(groupByColumns, AthenaInvoiceEntityNameColumn)
  142. }
  143. var selectColumns []string
  144. // Duplicate GroupBy Columns into select columns
  145. selectColumns = append(selectColumns, groupByColumns...)
  146. // Clean Up group by columns
  147. ai.RemoveColumnAliases(groupByColumns)
  148. // Build list cost column and add it to the select columns
  149. listCostColumn := ai.GetListCostColumn()
  150. selectColumns = append(selectColumns, listCostColumn)
  151. aqi.ListCostColumn = listCostColumn
  152. // Build net cost column and add it to select columns
  153. netCostColumn := ai.GetNetCostColumn(allColumns)
  154. selectColumns = append(selectColumns, netCostColumn)
  155. aqi.NetCostColumn = netCostColumn
  156. // Build amortized net cost column and add it to select columns
  157. amortizedNetCostColumn := ai.GetAmortizedNetCostColumn(allColumns)
  158. selectColumns = append(selectColumns, amortizedNetCostColumn)
  159. aqi.AmortizedNetCostColumn = amortizedNetCostColumn
  160. // Build Amortized cost column and add it to select columns
  161. amortizedCostColumn := ai.GetAmortizedCostColumn(allColumns)
  162. selectColumns = append(selectColumns, amortizedCostColumn)
  163. aqi.AmortizedCostColumn = amortizedCostColumn
  164. // Build map of query columns to use for parsing query
  165. aqi.ColumnIndexes = map[string]int{}
  166. for i, column := range selectColumns {
  167. aqi.ColumnIndexes[column] = i
  168. }
  169. whereDate := fmt.Sprintf(AthenaWhereDateFmt, start.Format("2006-01-02"), end.Format("2006-01-02"))
  170. wherePartitions := ai.GetPartitionWhere(start, end, isCUR20(allColumns))
  171. // Query for all line items whose usage start date falls within the given range and
  172. // partition, restricted to usage-driving line item types and, when the
  173. // bill_billing_entity column exists, AWS Marketplace fees (see GetWhereUsage).
  174. whereConjuncts := []string{
  175. wherePartitions,
  176. whereDate,
  177. ai.GetWhereUsage(allColumns),
  178. }
  179. columnStr := strings.Join(selectColumns, ", ")
  180. whereClause := strings.Join(whereConjuncts, " AND ")
  181. groupByStr := strings.Join(groupByColumns, ", ")
  182. queryStr := `
  183. SELECT %s
  184. FROM "%s"
  185. WHERE %s
  186. GROUP BY %s
  187. `
  188. if limit > 0 {
  189. queryStr = fmt.Sprintf("%s LIMIT %d", queryStr, limit)
  190. }
  191. aqi.Query = fmt.Sprintf(queryStr, columnStr, ai.Table, whereClause, groupByStr)
  192. ccsr, err := opencost.NewCloudCostSetRange(start, end, opencost.AccumulateOptionDay, ai.Key())
  193. if err != nil {
  194. return nil, err
  195. }
  196. // Generate row handling function.
  197. rowHandler := func(row types.Row) {
  198. cc, err2 := athenaRowToCloudCost(row, aqi)
  199. if err2 != nil {
  200. log.Errorf("AthenaIntegration: GetCloudCost: error while parsing row: %s", err2.Error())
  201. return
  202. }
  203. ccsr.LoadCloudCost(cc)
  204. }
  205. log.Debugf("AthenaIntegration[%s]: GetCloudCost: querying: %s", ai.Key(), aqi.Query)
  206. // Query CUR data and fill out CCSR
  207. err = ai.Query(context.TODO(), aqi.Query, GetAthenaQueryFunc(rowHandler))
  208. if err != nil {
  209. return nil, err
  210. }
  211. ai.ConnectionStatus = ai.GetConnectionStatusFromResult(ccsr, ai.ConnectionStatus)
  212. return ccsr, nil
  213. }
  214. func (ai *AthenaIntegration) GetListCostColumn() string {
  215. var listCostBuilder strings.Builder
  216. listCostBuilder.WriteString("CASE line_item_line_item_type")
  217. listCostBuilder.WriteString(" WHEN 'EdpDiscount' THEN 0")
  218. listCostBuilder.WriteString(" WHEN 'PrivateRateDiscount' THEN 0")
  219. listCostBuilder.WriteString(" ELSE ")
  220. listCostBuilder.WriteString(AthenaPricingColumn)
  221. listCostBuilder.WriteString(" END")
  222. return fmt.Sprintf("SUM(%s) as list_cost", listCostBuilder.String())
  223. }
  224. // GetWhereUsage returns the usage-type filter to apply to the CUR query. When the CUR
  225. // export includes bill_billing_entity, AWS Marketplace 'Fee' line items are included
  226. // alongside the usual usage-driving types (see AthenaWhereUsage). CUR 2.0 exports can
  227. // disable any column, so when bill_billing_entity is absent this falls back to
  228. // AthenaWhereUsageBase -- referencing a missing column would otherwise fail the entire
  229. // query with COLUMN_NOT_FOUND, not just omit Marketplace fees.
  230. func (ai *AthenaIntegration) GetWhereUsage(allColumns map[string]bool) string {
  231. if allColumns[AthenaBillingEntityColumn] {
  232. return AthenaWhereUsage
  233. }
  234. return AthenaWhereUsageBase
  235. }
  236. func (ai *AthenaIntegration) GetNetCostColumn(allColumns map[string]bool) string {
  237. netCostColumn := ""
  238. if allColumns[AthenaNetPricingColumn] { // if Net pricing exists
  239. netCostColumn = AthenaNetPricingCoalesce
  240. } else { // Non-net for if there's no net pricing.
  241. netCostColumn = AthenaPricingColumn
  242. }
  243. return fmt.Sprintf("SUM(%s) as net_cost", netCostColumn)
  244. }
  245. func (ai *AthenaIntegration) GetAmortizedCostColumn(allColumns map[string]bool) string {
  246. amortizedCostCase := ai.GetAmortizedCostCase(allColumns)
  247. return fmt.Sprintf("SUM(%s) as amortized_cost", amortizedCostCase)
  248. }
  249. func (ai *AthenaIntegration) GetAmortizedNetCostColumn(allColumns map[string]bool) string {
  250. amortizedNetCostCase := ""
  251. if allColumns[AthenaNetPricingColumn] { // if Net pricing exists
  252. amortizedNetCostCase = ai.GetAmortizedNetCostCase(allColumns)
  253. } else { // Non-net for if there's no net pricing.
  254. amortizedNetCostCase = ai.GetAmortizedCostCase(allColumns)
  255. }
  256. return fmt.Sprintf("SUM(%s) as amortized_net_cost", amortizedNetCostCase)
  257. }
  258. func (ai *AthenaIntegration) GetAmortizedCostCase(allColumns map[string]bool) string {
  259. // Use unblended costs if Reserved Instances/Savings Plans aren't in use
  260. if !allColumns[AthenaRIPricingColumn] && !allColumns[AthenaSPPricingColumn] {
  261. return AthenaPricingColumn
  262. }
  263. var costBuilder strings.Builder
  264. costBuilder.WriteString("CASE line_item_line_item_type")
  265. if allColumns[AthenaRIPricingColumn] {
  266. costBuilder.WriteString(" WHEN 'DiscountedUsage' THEN ")
  267. costBuilder.WriteString(AthenaRIPricingColumn)
  268. }
  269. if allColumns[AthenaSPPricingColumn] {
  270. costBuilder.WriteString(" WHEN 'SavingsPlanCoveredUsage' THEN ")
  271. costBuilder.WriteString(AthenaSPPricingColumn)
  272. }
  273. costBuilder.WriteString(" ELSE ")
  274. costBuilder.WriteString(AthenaPricingColumn)
  275. costBuilder.WriteString(" END")
  276. return costBuilder.String()
  277. }
  278. func (ai *AthenaIntegration) GetAmortizedNetCostCase(allColumns map[string]bool) string {
  279. // Use net unblended costs if Reserved Instances/Savings Plans aren't in use
  280. if !allColumns[AthenaNetRIPricingColumn] && !allColumns[AthenaNetSPPricingColumn] {
  281. return AthenaNetPricingCoalesce
  282. }
  283. var costBuilder strings.Builder
  284. costBuilder.WriteString("CASE line_item_line_item_type")
  285. if allColumns[AthenaNetRIPricingColumn] {
  286. costBuilder.WriteString(" WHEN 'DiscountedUsage' THEN ")
  287. costBuilder.WriteString(AthenaNetRIPricingCoalesce)
  288. }
  289. if allColumns[AthenaNetSPPricingColumn] {
  290. costBuilder.WriteString(" WHEN 'SavingsPlanCoveredUsage' THEN ")
  291. costBuilder.WriteString(AthenaNetSPPricingCoalesce)
  292. }
  293. costBuilder.WriteString(" ELSE ")
  294. costBuilder.WriteString(AthenaNetPricingCoalesce)
  295. costBuilder.WriteString(" END")
  296. return costBuilder.String()
  297. }
  298. func (ai *AthenaIntegration) RemoveColumnAliases(columns []string) {
  299. for i, column := range columns {
  300. if strings.Contains(column, " as ") {
  301. columnValues := strings.Split(column, " as ")
  302. columns[i] = columnValues[0]
  303. }
  304. }
  305. }
  306. func (ai *AthenaIntegration) ConvertLabelToAWSTag(label string) string {
  307. // if the label already has the column prefix assume that it is in the correct format
  308. if strings.HasPrefix(label, LabelColumnPrefix) {
  309. return label
  310. }
  311. // replace characters with underscore
  312. tag := label
  313. tag = strings.ReplaceAll(tag, ".", "_")
  314. tag = strings.ReplaceAll(tag, "/", "_")
  315. tag = strings.ReplaceAll(tag, ":", "_")
  316. tag = strings.ReplaceAll(tag, "-", "_")
  317. // add prefix and return
  318. return LabelColumnPrefix + tag
  319. }
  320. // GetIsKubernetesColumn builds a column that determines if a row represents kubernetes spend
  321. func (ai *AthenaIntegration) GetIsKubernetesColumn(allColumns map[string]bool) string {
  322. // tagColumns is a list of columns where the presence of a value indicates that a resource is part of a kubernetes cluster
  323. // Known columns hardcoded for CUR 1.0 and CUR 2.0
  324. tagColumnsIsK8sCUR10 := []string{
  325. "resource_tags_aws_eks_cluster_name",
  326. "resource_tags_user_eks_cluster_name",
  327. "resource_tags_user_alpha_eksctl_io_cluster_name",
  328. "resource_tags_user_kubernetes_io_service_name",
  329. "resource_tags_user_kubernetes_io_created_for_pvc_name",
  330. "resource_tags_user_kubernetes_io_created_for_pv_name",
  331. }
  332. tagColumnsIsK8sCUR20 := []string{
  333. "resource_tags['aws_eks_cluster_name']",
  334. "resource_tags['user_eks_cluster_name']",
  335. "resource_tags['user_alpha_eksctl_io_cluster_name']",
  336. "resource_tags['user_kubernetes_io_service_name']",
  337. "resource_tags['user_kubernetes_io_created_for_pvc_name']",
  338. "resource_tags['user_kubernetes_io_created_for_pv_name']",
  339. }
  340. disjuncts := []string{
  341. "line_item_product_code = 'AmazonEKS'", // EKS is always kubernetes
  342. }
  343. if allColumns[AthenaResourceTagsColumn] {
  344. // if resource tags column is present in the CUR check for IsKubernetes keys in the resource tags map
  345. for _, tagColumn := range tagColumnsIsK8sCUR20 {
  346. disjunctStr := fmt.Sprintf("COALESCE(%s, '') <> ''", tagColumn)
  347. disjuncts = append(disjuncts, disjunctStr)
  348. }
  349. } else {
  350. for _, tagColumn := range tagColumnsIsK8sCUR10 {
  351. // if tag column is present in the CUR check for it
  352. if _, ok := allColumns[tagColumn]; ok {
  353. disjunctStr := fmt.Sprintf("%s <> ''", tagColumn)
  354. disjuncts = append(disjuncts, disjunctStr)
  355. }
  356. }
  357. }
  358. return fmt.Sprintf("(%s) as is_kubernetes", strings.Join(disjuncts, " OR "))
  359. }
  360. func (ai *AthenaIntegration) GetPartitionWhere(start, end time.Time, isCUR20 bool) string {
  361. month := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC)
  362. endMonth := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, time.UTC)
  363. var disjuncts []string
  364. for !month.After(endMonth) {
  365. if isCUR20 {
  366. // CUR 2.0 with billing_period partitions
  367. disjuncts = append(disjuncts, fmt.Sprintf("(billing_period = '%d-%02d')", month.Year(), month.Month()))
  368. } else {
  369. // CUR 1.0 uses year and month columns for partitioning
  370. disjuncts = append(disjuncts, fmt.Sprintf("(year = '%d' AND month = '%d')", month.Year(), month.Month()))
  371. }
  372. month = month.AddDate(0, 1, 0)
  373. }
  374. str := fmt.Sprintf("(%s)", strings.Join(disjuncts, " OR "))
  375. return str
  376. }
  377. func athenaRowToCloudCost(row types.Row, aqi AthenaQueryIndexes) (*opencost.CloudCost, error) {
  378. if len(row.Data) < len(aqi.ColumnIndexes) {
  379. return nil, fmt.Errorf("rowToCloudCost: row with fewer than %d columns (has only %d)", len(aqi.ColumnIndexes), len(row.Data))
  380. }
  381. // Iterate through the slice of tag columns, assigning
  382. // values to the column names, minus the tag prefix.
  383. labels := opencost.CloudCostLabels{}
  384. for _, tagColumnName := range aqi.TagColumns {
  385. // remove quotes
  386. labelName := strings.TrimPrefix(tagColumnName, `"`)
  387. labelName = strings.TrimSuffix(labelName, `"`)
  388. // remove prefix
  389. labelName = strings.TrimPrefix(labelName, LabelColumnPrefix)
  390. value := GetAthenaRowValue(row, aqi.ColumnIndexes, tagColumnName)
  391. if value != "" {
  392. labels[labelName] = value
  393. }
  394. }
  395. for _, awsColumnName := range aqi.AWSTagColumns {
  396. // partially remove prefix leaving "aws_"
  397. labelName := strings.TrimPrefix(awsColumnName, AthenaResourceTagPrefix)
  398. value := GetAthenaRowValue(row, aqi.ColumnIndexes, awsColumnName)
  399. if value != "" {
  400. labels[labelName] = value
  401. }
  402. }
  403. if _, ok := aqi.ColumnIndexes[AthenaResourceTagsCastToJsonColumn]; ok {
  404. resourceTags := GetAthenaRowValue(row, aqi.ColumnIndexes, AthenaResourceTagsCastToJsonColumn)
  405. rawTags := map[string]string{}
  406. err := json.Unmarshal([]byte(resourceTags), &rawTags)
  407. if err != nil {
  408. log.Errorf("athenaRowToCloudCost: error unmarshalling resource tags: %s", err.Error())
  409. }
  410. // aws tags keep their prefix
  411. for tagKey, value := range rawTags {
  412. if !strings.HasPrefix(tagKey, AthenaResourceTagsUserPrefix) && value != "" {
  413. labels[tagKey] = value
  414. }
  415. }
  416. // remove "user_" prefix, aws tags take precedence
  417. for tagKey, value := range rawTags {
  418. if !strings.HasPrefix(tagKey, AthenaResourceTagsUserPrefix) || value == "" {
  419. continue
  420. }
  421. labelName := strings.TrimPrefix(tagKey, AthenaResourceTagsUserPrefix)
  422. if _, exists := labels[labelName]; !exists {
  423. labels[labelName] = value
  424. }
  425. }
  426. }
  427. invoiceEntityID := GetAthenaRowValue(row, aqi.ColumnIndexes, "bill_payer_account_id")
  428. accountID := GetAthenaRowValue(row, aqi.ColumnIndexes, "line_item_usage_account_id")
  429. invoiceEntityName := invoiceEntityID
  430. accountName := accountID
  431. if _, ok := aqi.ColumnIndexes[AthenaInvoiceEntityNameColumn]; ok {
  432. invoiceEntityName = GetAthenaRowValue(row, aqi.ColumnIndexes, AthenaInvoiceEntityNameColumn)
  433. }
  434. if _, ok := aqi.ColumnIndexes[AthenaAccountNameColumn]; ok {
  435. accountName = GetAthenaRowValue(row, aqi.ColumnIndexes, AthenaAccountNameColumn)
  436. }
  437. startStr := GetAthenaRowValue(row, aqi.ColumnIndexes, AthenaDateTruncColumn)
  438. providerID := GetAthenaRowValue(row, aqi.ColumnIndexes, "line_item_resource_id")
  439. productCode := GetAthenaRowValue(row, aqi.ColumnIndexes, "line_item_product_code")
  440. usageType := GetAthenaRowValue(row, aqi.ColumnIndexes, "line_item_usage_type")
  441. regionCode := GetAthenaRowValue(row, aqi.ColumnIndexes, "product_region_code")
  442. availabilityZone := GetAthenaRowValue(row, aqi.ColumnIndexes, "line_item_availability_zone")
  443. isK8s, _ := strconv.ParseBool(GetAthenaRowValue(row, aqi.ColumnIndexes, aqi.IsK8sColumn))
  444. k8sPct := 0.0
  445. if isK8s {
  446. k8sPct = 1.0
  447. }
  448. listCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.ListCostColumn)
  449. if err != nil {
  450. return nil, err
  451. }
  452. netCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.NetCostColumn)
  453. if err != nil {
  454. return nil, err
  455. }
  456. amortizedNetCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.AmortizedNetCostColumn)
  457. if err != nil {
  458. return nil, err
  459. }
  460. amortizedCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.AmortizedCostColumn)
  461. if err != nil {
  462. return nil, err
  463. }
  464. // Identify resource category in the CUR
  465. category := SelectAWSCategory(providerID, usageType, productCode)
  466. // Retrieve final stanza of product code for ProviderID
  467. if productCode == "AWSELB" || productCode == "AmazonFSx" {
  468. providerID = ParseARN(providerID)
  469. }
  470. if productCode == "AmazonEKS" && category == opencost.ComputeCategory {
  471. if strings.Contains(usageType, "CPU") {
  472. providerID = fmt.Sprintf("%s/CPU", providerID)
  473. } else if strings.Contains(usageType, "GB") {
  474. providerID = fmt.Sprintf("%s/RAM", providerID)
  475. }
  476. }
  477. properties := opencost.CloudCostProperties{
  478. ProviderID: providerID,
  479. Provider: opencost.AWSProvider,
  480. AccountID: accountID,
  481. AccountName: accountName,
  482. InvoiceEntityID: invoiceEntityID,
  483. InvoiceEntityName: invoiceEntityName,
  484. RegionID: regionCode,
  485. AvailabilityZone: availabilityZone,
  486. Service: productCode,
  487. Category: category,
  488. Labels: labels,
  489. }
  490. start, err := time.Parse(AthenaDateLayout, startStr)
  491. if err != nil {
  492. return nil, fmt.Errorf("unable to parse %s: '%s'", AthenaDateTruncColumn, err.Error())
  493. }
  494. end := start.AddDate(0, 0, 1)
  495. cc := &opencost.CloudCost{
  496. Properties: &properties,
  497. Window: opencost.NewWindow(&start, &end),
  498. ListCost: opencost.CostMetric{
  499. Cost: listCost,
  500. KubernetesPercent: k8sPct,
  501. },
  502. NetCost: opencost.CostMetric{
  503. Cost: netCost,
  504. KubernetesPercent: k8sPct,
  505. },
  506. AmortizedNetCost: opencost.CostMetric{
  507. Cost: amortizedNetCost,
  508. KubernetesPercent: k8sPct,
  509. },
  510. AmortizedCost: opencost.CostMetric{
  511. Cost: amortizedCost,
  512. KubernetesPercent: k8sPct,
  513. },
  514. InvoicedCost: opencost.CostMetric{
  515. Cost: netCost, // We are using Net Cost for Invoiced Cost for now as it is the closest approximation
  516. KubernetesPercent: k8sPct,
  517. },
  518. }
  519. return cc, nil
  520. }
  521. func (ai *AthenaIntegration) GetConnectionStatusFromResult(result cloud.EmptyChecker, currentStatus cloud.ConnectionStatus) cloud.ConnectionStatus {
  522. if result.IsEmpty() && currentStatus != cloud.SuccessfulConnection {
  523. return cloud.MissingData
  524. }
  525. return cloud.SuccessfulConnection
  526. }
  527. // presence of any of resource_tags, line_item_usage_account_name, or bill_payer_account_name columns confirms CUR 2.0
  528. func isCUR20(allColumns map[string]bool) bool {
  529. return allColumns[AthenaResourceTagsColumn] || allColumns[AthenaAccountNameColumn] || allColumns[AthenaInvoiceEntityNameColumn]
  530. }