2
0

billingexportparser.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. package azure
  2. import (
  3. "fmt"
  4. "regexp"
  5. "strconv"
  6. "strings"
  7. "time"
  8. "github.com/opencost/opencost/pkg/kubecost"
  9. "github.com/opencost/opencost/pkg/log"
  10. "github.com/opencost/opencost/pkg/util/json"
  11. )
  12. const azureDateLayout = "2006-01-02"
  13. const AzureEnterpriseDateLayout = "01/02/2006"
  14. var groupRegex = regexp.MustCompile("(/[^/]+)")
  15. // BillingRowValues holder for Azure Billing Values
  16. type BillingRowValues struct {
  17. Date time.Time
  18. MeterCategory string
  19. SubscriptionID string
  20. InvoiceEntityID string
  21. InstanceID string
  22. Service string
  23. Tags map[string]string
  24. AdditionalInfo map[string]any
  25. Cost float64
  26. NetCost float64
  27. }
  28. func (brv *BillingRowValues) IsCompute(category string) bool {
  29. if category == kubecost.ComputeCategory {
  30. return true
  31. }
  32. if category == kubecost.StorageCategory || category == kubecost.NetworkCategory {
  33. if brv.Service == "Microsoft.Compute" {
  34. return true
  35. }
  36. }
  37. if category == kubecost.NetworkCategory && brv.MeterCategory == "Virtual Network" {
  38. return true
  39. }
  40. return false
  41. }
  42. // BillingExportParser holds indexes of relevent fields in Azure Billing CSV in addition to the correct data format
  43. type BillingExportParser struct {
  44. Date int
  45. MeterCategory int
  46. InvoiceEntityID int
  47. SubscriptionID int
  48. InstanceID int
  49. Service int
  50. Tags int
  51. AdditionalInfo int
  52. Cost int
  53. NetCost int
  54. DateFormat string
  55. }
  56. // match "SubscriptionGuid" in "Abonnement-GUID (SubscriptionGuid)"
  57. var getParenContentRegEx = regexp.MustCompile("\\((.*?)\\)")
  58. func NewBillingParseSchema(headers []string) (*BillingExportParser, error) {
  59. // clear BOM from headers
  60. if len(headers) != 0 {
  61. headers[0] = strings.TrimPrefix(headers[0], "\xEF\xBB\xBF")
  62. }
  63. headerIndexes := map[string]int{}
  64. for i, header := range headers {
  65. // Azure Headers in different regions will have english headers in parentheses
  66. match := getParenContentRegEx.FindStringSubmatch(header)
  67. if len(match) != 0 {
  68. header = match[len(match)-1]
  69. }
  70. headerIndexes[strings.ToLower(header)] = i
  71. }
  72. abp := &BillingExportParser{}
  73. // Set Date Column and Date Format
  74. if i, ok := headerIndexes["usagedatetime"]; ok {
  75. abp.Date = i
  76. abp.DateFormat = azureDateLayout
  77. } else if j, ok2 := headerIndexes["date"]; ok2 {
  78. abp.Date = j
  79. abp.DateFormat = AzureEnterpriseDateLayout
  80. } else {
  81. return nil, fmt.Errorf("NewBillingParseSchema: failed to find Date field")
  82. }
  83. // set Subscription ID
  84. if i, ok := headerIndexes["subscriptionid"]; ok {
  85. abp.SubscriptionID = i
  86. } else if j, ok2 := headerIndexes["subscriptionguid"]; ok2 {
  87. abp.SubscriptionID = j
  88. } else {
  89. return nil, fmt.Errorf("NewBillingParseSchema: failed to find Subscription ID field")
  90. }
  91. // Set Billing ID
  92. if i, ok := headerIndexes["billingaccountid"]; ok {
  93. abp.InvoiceEntityID = i
  94. } else if j, ok2 := headerIndexes["billingaccountname"]; ok2 {
  95. abp.InvoiceEntityID = j
  96. } else {
  97. // if no billing ID column is present use subscription ID
  98. abp.InvoiceEntityID = abp.SubscriptionID
  99. }
  100. // Set Instance ID
  101. if i, ok := headerIndexes["instanceid"]; ok {
  102. abp.InstanceID = i
  103. } else if j, ok2 := headerIndexes["instancename"]; ok2 {
  104. abp.InstanceID = j
  105. } else if k, ok3 := headerIndexes["resourceid"]; ok3 {
  106. abp.InstanceID = k
  107. } else {
  108. return nil, fmt.Errorf("NewBillingParseSchema: failed to find Instance ID field")
  109. }
  110. // Set Meter Category
  111. if i, ok := headerIndexes["metercategory"]; ok {
  112. abp.MeterCategory = i
  113. } else {
  114. return nil, fmt.Errorf("NewBillingParseSchema: failed to find Meter Category field")
  115. }
  116. // Set Tags
  117. if i, ok := headerIndexes["tags"]; ok {
  118. abp.Tags = i
  119. } else {
  120. return nil, fmt.Errorf("NewBillingParseSchema: failed to find Tags field")
  121. }
  122. // Set Additional Info
  123. if i, ok := headerIndexes["additionalinfo"]; ok {
  124. abp.AdditionalInfo = i
  125. } else {
  126. return nil, fmt.Errorf("NewBillingParseSchema: failed to find Additional Info field")
  127. }
  128. // Set Service
  129. if i, ok := headerIndexes["consumedservice"]; ok {
  130. abp.Service = i
  131. } else {
  132. return nil, fmt.Errorf("NewBillingParseSchema: failed to find Service field")
  133. }
  134. // Set Net Cost
  135. if i, ok := headerIndexes["costinbillingcurrency"]; ok {
  136. abp.NetCost = i
  137. } else if j, ok2 := headerIndexes["pretaxcost"]; ok2 {
  138. abp.NetCost = j
  139. } else if k, ok3 := headerIndexes["cost"]; ok3 {
  140. abp.NetCost = k
  141. } else {
  142. return nil, fmt.Errorf("NewBillingParseSchema: failed to find Net Cost field")
  143. }
  144. // Set Cost
  145. if i, ok := headerIndexes["paygcostinbillingcurrency"]; ok {
  146. abp.Cost = i
  147. } else {
  148. // if no Cost column is present use Net Cost column
  149. abp.Cost = abp.NetCost
  150. }
  151. return abp, nil
  152. }
  153. func (bep *BillingExportParser) ParseRow(start, end time.Time, record []string) *BillingRowValues {
  154. usageDate, err := time.Parse(bep.DateFormat, record[bep.Date])
  155. if err != nil {
  156. // try other format, and switch if successful
  157. if bep.DateFormat == azureDateLayout {
  158. bep.DateFormat = AzureEnterpriseDateLayout
  159. } else {
  160. bep.DateFormat = azureDateLayout
  161. }
  162. usageDate, err = time.Parse(bep.DateFormat, record[bep.Date])
  163. // If parse still fails then return line
  164. if err != nil {
  165. log.Errorf("failed to parse usage date: '%s'", record[bep.Date])
  166. return nil
  167. }
  168. }
  169. // skip if usage data isn't in subject window
  170. if usageDate.Before(start) || !usageDate.Before(end) {
  171. return nil
  172. }
  173. cost, err := strconv.ParseFloat(record[bep.Cost], 64)
  174. if err != nil {
  175. log.Errorf("failed to parse cost: '%s'", record[bep.Cost])
  176. return nil
  177. }
  178. netCost, err := strconv.ParseFloat(record[bep.NetCost], 64)
  179. if err != nil {
  180. log.Errorf("failed to parse net cost: '%s'", record[bep.NetCost])
  181. return nil
  182. }
  183. additionalInfo := make(map[string]any)
  184. additionalInfoJson := encloseInBrackets(record[bep.AdditionalInfo])
  185. if additionalInfoJson != "" {
  186. err = json.Unmarshal([]byte(additionalInfoJson), &additionalInfo)
  187. if err != nil {
  188. log.Errorf("Could not parse additional information %s, with Error: %s", additionalInfoJson, err.Error())
  189. }
  190. }
  191. tags := make(map[string]string)
  192. tagJson := encloseInBrackets(record[bep.Tags])
  193. if tagJson != "" {
  194. tagsAny := make(map[string]any)
  195. err = json.Unmarshal([]byte(tagJson), &tagsAny)
  196. if err != nil {
  197. log.Errorf("Could not parse tags: %v, with Error: %s", tagJson, err.Error())
  198. }
  199. for name, value := range tagsAny {
  200. if valueStr, ok := value.(string); ok && valueStr != "" {
  201. tags[name] = valueStr
  202. }
  203. }
  204. }
  205. return &BillingRowValues{
  206. Date: usageDate,
  207. MeterCategory: record[bep.MeterCategory],
  208. SubscriptionID: record[bep.SubscriptionID],
  209. InvoiceEntityID: record[bep.InvoiceEntityID],
  210. InstanceID: record[bep.InstanceID],
  211. Service: record[bep.Service],
  212. Tags: tags,
  213. AdditionalInfo: additionalInfo,
  214. Cost: cost,
  215. NetCost: netCost,
  216. }
  217. }
  218. // enclose json strings in brackets if they are missing
  219. func encloseInBrackets(jsonString string) string {
  220. if jsonString == "" || (jsonString[0] == '{' && jsonString[len(jsonString)-1] == '}') {
  221. return jsonString
  222. }
  223. return fmt.Sprintf("{%s}", jsonString)
  224. }
  225. func AzureSetProviderID(abv *BillingRowValues) string {
  226. category := SelectAzureCategory(abv.MeterCategory)
  227. if value, ok := abv.AdditionalInfo["VMName"]; ok {
  228. return "azure://" + resourceGroupToLowerCase(abv.InstanceID) + getVMNumberForVMSS(fmt.Sprintf("%v", value))
  229. } else if value, ok := abv.AdditionalInfo["VmName"]; ok {
  230. return "azure://" + resourceGroupToLowerCase(abv.InstanceID) + getVMNumberForVMSS(fmt.Sprintf("%v", value))
  231. } else if value2, ook := abv.AdditionalInfo["IpAddress"]; ook && abv.MeterCategory == "Virtual Network" {
  232. return fmt.Sprintf("%v", value2)
  233. }
  234. if category == kubecost.StorageCategory {
  235. if value2, ok2 := abv.Tags["creationSource"]; ok2 {
  236. creationSource := fmt.Sprintf("%v", value2)
  237. return strings.TrimPrefix(creationSource, "aks-")
  238. } else if value2, ok2 := abv.Tags["aks-managed-creationSource"]; ok2 {
  239. creationSource := fmt.Sprintf("%v", value2)
  240. return strings.TrimPrefix(creationSource, "vmssclient-")
  241. } else {
  242. return getSubStringAfterFinalSlash(abv.InstanceID)
  243. }
  244. }
  245. return "azure://" + resourceGroupToLowerCase(abv.InstanceID)
  246. }
  247. func SelectAzureCategory(meterCategory string) string {
  248. if meterCategory == "Virtual Machines" {
  249. return kubecost.ComputeCategory
  250. } else if meterCategory == "Storage" {
  251. return kubecost.StorageCategory
  252. } else if meterCategory == "Load Balancer" || meterCategory == "Bandwidth" || meterCategory == "Virtual Network" {
  253. return kubecost.NetworkCategory
  254. } else {
  255. return kubecost.OtherCategory
  256. }
  257. }
  258. func resourceGroupToLowerCase(providerID string) string {
  259. var sb strings.Builder
  260. for matchNum, group := range groupRegex.FindAllString(providerID, -1) {
  261. if matchNum == 3 {
  262. sb.WriteString(strings.ToLower(group))
  263. } else {
  264. sb.WriteString(group)
  265. }
  266. }
  267. return sb.String()
  268. }
  269. // Returns the substring after the final "/" in a string
  270. func getSubStringAfterFinalSlash(id string) string {
  271. index := strings.LastIndex(id, "/")
  272. if index == -1 {
  273. log.DedupedInfof(5, "azure.getSubStringAfterFinalSlash: failed to parse %s", id)
  274. return id
  275. }
  276. return id[index+1:]
  277. }
  278. func getVMNumberForVMSS(vmName string) string {
  279. vmNameSplit := strings.Split(vmName, "_")
  280. if len(vmNameSplit) > 1 {
  281. return "/virtualMachines/" + vmNameSplit[1]
  282. }
  283. return ""
  284. }