2
0

pricesheetdownloader.go 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. package azure
  2. import (
  3. "bufio"
  4. "context"
  5. "encoding/csv"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "os"
  10. "sort"
  11. "strconv"
  12. "strings"
  13. "time"
  14. "github.com/Azure/azure-sdk-for-go/profiles/2020-09-01/commerce/mgmt/commerce"
  15. "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
  16. "github.com/Azure/azure-sdk-for-go/sdk/azidentity"
  17. "github.com/opencost/opencost/core/pkg/log"
  18. )
  19. type PriceSheetDownloader struct {
  20. TenantID string
  21. ClientID string
  22. ClientSecret string
  23. BillingAccount string
  24. OfferID string
  25. ConvertMeterInfo func(info commerce.MeterInfo) (map[string]*AzurePricing, error)
  26. }
  27. func (d *PriceSheetDownloader) GetPricing(ctx context.Context) (map[string]*AzurePricing, error) {
  28. log.Infof("requesting pricesheet download link")
  29. url, err := d.getDownloadURL(ctx)
  30. if err != nil {
  31. return nil, fmt.Errorf("getting download URL: %w", err)
  32. }
  33. log.Infof("downloading pricesheet from %q", url)
  34. data, err := d.saveData(ctx, url, "pricesheet")
  35. if err != nil {
  36. return nil, fmt.Errorf("saving pricesheet from %q: %w", url, err)
  37. }
  38. defer data.Close()
  39. prices, err := d.readPricesheet(ctx, data)
  40. if err != nil {
  41. return nil, fmt.Errorf("reading pricesheet: %w", err)
  42. }
  43. log.Infof("loaded %d pricings from pricesheet", len(prices))
  44. return prices, nil
  45. }
  46. func (d *PriceSheetDownloader) getDownloadURL(ctx context.Context) (string, error) {
  47. cred, err := azidentity.NewClientSecretCredential(d.TenantID, d.ClientID, d.ClientSecret, nil)
  48. if err != nil {
  49. return "", fmt.Errorf("creating credential: %w", err)
  50. }
  51. client, err := NewPriceSheetClient(d.BillingAccount, cred, nil)
  52. if err != nil {
  53. return "", fmt.Errorf("creating pricesheet client: %w", err)
  54. }
  55. poller, err := client.BeginDownloadByBillingPeriod(ctx, currentBillingPeriod())
  56. if err != nil {
  57. return "", fmt.Errorf("beginning pricesheet download: %w", err)
  58. }
  59. resp, err := poller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{
  60. Frequency: 30 * time.Second,
  61. })
  62. if err != nil {
  63. return "", fmt.Errorf("polling for pricesheet: %w", err)
  64. }
  65. return resp.Properties.DownloadURL, nil
  66. }
  67. func (d PriceSheetDownloader) saveData(ctx context.Context, url, tempName string) (io.ReadCloser, error) {
  68. // Download file from URL in response.
  69. out, err := os.CreateTemp("", tempName)
  70. if err != nil {
  71. return nil, fmt.Errorf("creating %s temp file: %w", tempName, err)
  72. }
  73. resp, err := http.Get(url)
  74. if err != nil {
  75. return nil, fmt.Errorf("downloading: %w", err)
  76. }
  77. defer resp.Body.Close()
  78. if resp.StatusCode != http.StatusOK {
  79. return nil, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode)
  80. }
  81. if _, err := io.Copy(out, resp.Body); err != nil {
  82. return nil, fmt.Errorf("reading response: %w", err)
  83. }
  84. _, err = out.Seek(0, io.SeekStart)
  85. if err != nil {
  86. return nil, fmt.Errorf("seeking to start of file: %w", err)
  87. }
  88. return &removeOnClose{File: out}, nil
  89. }
  90. type removeOnClose struct {
  91. *os.File
  92. }
  93. func (r *removeOnClose) Close() error {
  94. err := r.File.Close()
  95. if err != nil {
  96. return err
  97. }
  98. return os.Remove(r.Name())
  99. }
  100. func (d *PriceSheetDownloader) readPricesheet(ctx context.Context, data io.Reader) (map[string]*AzurePricing, error) {
  101. // Avoid double-buffering.
  102. buf, ok := (data).(*bufio.Reader)
  103. if !ok {
  104. buf = bufio.NewReader(data)
  105. }
  106. // The CSV file starts with two lines before the header without
  107. // commas (so different numbers of fields as far as the CSV parser
  108. // is concerned). Skip them before making the CSV reader so we
  109. // still get the benefit of the row length checks after the
  110. // header.
  111. for i := 0; i < 2; i++ {
  112. _, err := buf.ReadBytes('\n')
  113. if err != nil {
  114. return nil, fmt.Errorf("skipping preamble line %d: %w", i, err)
  115. }
  116. }
  117. reader := csv.NewReader(buf)
  118. reader.ReuseRecord = true
  119. header, err := reader.Read()
  120. if err != nil {
  121. return nil, fmt.Errorf("reading header: %w", err)
  122. }
  123. if err := checkPricesheetHeader(header); err != nil {
  124. return nil, err
  125. }
  126. units := make(map[string]bool)
  127. results := make(map[string]*AzurePricing)
  128. lines := 2
  129. for {
  130. row, err := reader.Read()
  131. if err == io.EOF {
  132. break
  133. }
  134. lines++
  135. if err != nil {
  136. return nil, fmt.Errorf("reading line %d: %w", lines, err)
  137. }
  138. // Skip savings plan - we should be reporting based on the
  139. // consumption price because we don't know whether the user is
  140. // using a savings plan or over their threshold.
  141. if row[pricesheetPriceType] == "Savings Plan" || row[pricesheetOfferID] != d.OfferID {
  142. continue
  143. }
  144. // TODO: Creating a meter info for each record will cause a
  145. // lot of GC churn - is it worth reusing one meter info instead?
  146. meterInfo, err := makeMeterInfo(row)
  147. if err != nil {
  148. log.Warnf("making meter info (line %d): %v", lines, err)
  149. continue
  150. }
  151. pricings, err := d.ConvertMeterInfo(meterInfo)
  152. if err != nil {
  153. log.Warnf("converting meter to pricings (line %d): %v", lines, err)
  154. continue
  155. }
  156. if pricings != nil {
  157. units[*meterInfo.Unit] = true
  158. }
  159. for key, pricing := range pricings {
  160. results[key] = pricing
  161. }
  162. }
  163. if len(results) == 0 {
  164. return nil, fmt.Errorf("no matching pricing from price sheet")
  165. }
  166. // Keep track of units seen so we can detect if there are any that
  167. // need handling.
  168. allUnits := make([]string, 0, len(units))
  169. for unit := range units {
  170. allUnits = append(allUnits, unit)
  171. }
  172. sort.Strings(allUnits)
  173. log.Infof("all units in pricesheet: %s", strings.Join(allUnits, ", "))
  174. return results, nil
  175. }
  176. func checkPricesheetHeader(header []string) error {
  177. if len(header) < len(pricesheetCols) {
  178. return fmt.Errorf("too few header columns: got %d, expected %d", len(header), len(pricesheetCols))
  179. }
  180. for col, name := range pricesheetCols {
  181. if !strings.EqualFold(header[col], name) {
  182. return fmt.Errorf("unexpected header at col %d %q, expected %q", col, header[col], name)
  183. }
  184. }
  185. return nil
  186. }
  187. func makeMeterInfo(row []string) (commerce.MeterInfo, error) {
  188. price, err := strconv.ParseFloat(row[pricesheetUnitPrice], 64)
  189. if err != nil {
  190. return commerce.MeterInfo{}, fmt.Errorf("parsing unit price: %w", err)
  191. }
  192. newPrice, unit := normalisePrice(price, row[pricesheetUnit])
  193. return commerce.MeterInfo{
  194. MeterName: ptr(row[pricesheetMeterName]),
  195. MeterCategory: ptr(row[pricesheetMeterCategory]),
  196. MeterSubCategory: ptr(row[pricesheetMeterSubCategory]),
  197. Unit: &unit,
  198. MeterRegion: ptr(row[pricesheetMeterRegion]),
  199. MeterRates: map[string]*float64{"0": &newPrice},
  200. }, nil
  201. }
  202. var pricesheetCols = []string{
  203. "Meter ID",
  204. "Meter name",
  205. "Meter category",
  206. "Meter sub-category",
  207. "Meter region",
  208. "Unit",
  209. "Unit of measure",
  210. "Part number",
  211. "Unit price",
  212. "Currency code",
  213. "Included quantity",
  214. "Offer Id",
  215. "Term",
  216. "Price type",
  217. }
  218. const (
  219. pricesheetMeterID = 0
  220. pricesheetMeterName = 1
  221. pricesheetMeterCategory = 2
  222. pricesheetMeterSubCategory = 3
  223. pricesheetMeterRegion = 4
  224. pricesheetUnit = 5
  225. pricesheetUnitPrice = 8
  226. pricesheetCurrencyCode = 9
  227. pricesheetOfferID = 11
  228. pricesheetPriceType = 13
  229. )
  230. func currentBillingPeriod() string {
  231. return time.Now().Format("200601")
  232. }
  233. func ptr[T any](v T) *T {
  234. return &v
  235. }
  236. // conversions lists all the units seen from the price sheet for
  237. // prices we're interested in with factors to the corresponding units
  238. // in the rate card.
  239. var conversions = map[string]struct {
  240. divisor float64
  241. unit string
  242. }{
  243. "1 /Month": {divisor: 1, unit: "1 /Month"},
  244. "1 Hour": {divisor: 1, unit: "1 Hour"},
  245. "1 PiB/Hour": {divisor: 1_000_000, unit: "1 GiB/Hour"},
  246. "10 /Month": {divisor: 10, unit: "1 /Month"},
  247. "10 Hours": {divisor: 10, unit: "1 Hour"},
  248. "100 /Month": {divisor: 100, unit: "1 /Month"},
  249. "100 GB/Month": {divisor: 100, unit: "1 GB/Month"},
  250. "100 Hours": {divisor: 100, unit: "1 Hour"},
  251. "100 TiB/Hour": {divisor: 100_000, unit: "1 GiB/Hour"},
  252. "1000 Hours": {divisor: 1000, unit: "1 Hour"},
  253. "10000 Hours": {divisor: 10_000, unit: "1 Hour"},
  254. "100000 /Hour": {divisor: 100_000, unit: "1 /Hour"},
  255. "1000000 /Hour": {divisor: 1_000_000, unit: "1 /Hour"},
  256. "10000000 /Hour": {divisor: 10_000_000, unit: "1 /Hour"},
  257. }
  258. func normalisePrice(price float64, unit string) (float64, string) {
  259. if conv, ok := conversions[unit]; ok {
  260. return price / conv.divisor, conv.unit
  261. }
  262. return price, unit
  263. }