2
0

billingexportparser.go 11 KB

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