billingexportparser.go 9.6 KB

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