main.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. package main
  2. import (
  3. "bufio"
  4. "encoding/json"
  5. "fmt"
  6. "os"
  7. "strings"
  8. "github.com/opencost/opencost/core/pkg/log"
  9. "github.com/opencost/opencost/core/pkg/pricing"
  10. "github.com/opencost/opencost/core/pkg/unit"
  11. "github.com/opencost/opencost/modules/pricing/public"
  12. "github.com/spf13/cobra"
  13. )
  14. var (
  15. currency string
  16. compare bool
  17. outputDir string
  18. )
  19. func main() {
  20. if err := rootCmd.Execute(); err != nil {
  21. fmt.Fprintf(os.Stderr, "Error: %v\n", err)
  22. os.Exit(1)
  23. }
  24. }
  25. var rootCmd = &cobra.Command{
  26. Use: "fetch-pricing",
  27. Short: "Fetch cloud provider pricing data",
  28. Long: `Fetch pricing data from a cloud provider and output as JSONL files.`,
  29. RunE: run,
  30. }
  31. func init() {
  32. rootCmd.Flags().StringVarP(&currency, "currency", "c", "USD", "Currency code (e.g. USD, CNY). Default: USD")
  33. rootCmd.Flags().BoolVar(&compare, "compare", false, "Compare freshly fetched pricing against the existing JSONL files; exits 2 if they differ")
  34. rootCmd.Flags().StringVarP(&outputDir, "output", "o", "..", "Base output directory")
  35. }
  36. func run(cmd *cobra.Command, args []string) error {
  37. curr, err := unit.ParseCurrency(currency)
  38. if err != nil {
  39. return fmt.Errorf("invalid currency '%s': %w", currency, err)
  40. }
  41. log.Infof("Generating pricing for %s", curr)
  42. pricingSet, err := public.GeneratePricing(curr)
  43. if err != nil {
  44. return fmt.Errorf("failed to generate pricing: %w", err)
  45. }
  46. log.Infof("Generated %d node pricing entries and %d volume pricing entries",
  47. len(pricingSet.NodePricing), len(pricingSet.PersistentVolumePricing))
  48. if compare {
  49. return comparePricing(curr, pricingSet)
  50. }
  51. dir := fmt.Sprintf("%s/%s", outputDir, strings.ToLower(string(curr)))
  52. return writePricingJSONL(dir, pricingSet)
  53. }
  54. // writePricingJSONL writes each pricing kind to its own JSONL file under dir.
  55. func writePricingJSONL(dir string, ps *pricing.PricingSet) error {
  56. if err := writeJSONL(dir+"/nodes.jsonl", ps.NodePricing); err != nil {
  57. return err
  58. }
  59. if err := writeJSONL(dir+"/persistentvolumes.jsonl", ps.PersistentVolumePricing); err != nil {
  60. return err
  61. }
  62. return nil
  63. }
  64. // writeJSONL marshals each item in items as a single line and writes them to path.
  65. func writeJSONL[T any](path string, items []T) error {
  66. f, err := os.Create(path)
  67. if err != nil {
  68. return fmt.Errorf("creating %s: %w", path, err)
  69. }
  70. defer f.Close()
  71. w := bufio.NewWriter(f)
  72. enc := json.NewEncoder(w) // Encode appends a trailing newline after each value.
  73. for _, item := range items {
  74. if err := enc.Encode(item); err != nil {
  75. return fmt.Errorf("encoding record to %s: %w", path, err)
  76. }
  77. }
  78. if err := w.Flush(); err != nil {
  79. return fmt.Errorf("flushing %s: %w", path, err)
  80. }
  81. log.Infof("Wrote %d records to %s", len(items), path)
  82. return nil
  83. }
  84. // comparePricing compares a fresh pricing set against the existing JSONL files
  85. // for a given currency.
  86. func comparePricing(curr unit.Currency, newSet *pricing.PricingSet) error {
  87. dir := fmt.Sprintf("%s/%s", outputDir, strings.ToLower(string(curr)))
  88. existingSet, err := readPricingJSONL(dir)
  89. if err != nil {
  90. return fmt.Errorf("reading existing pricing data from %s: %w", dir, err)
  91. }
  92. existingSet.Sort()
  93. newChecksum, err := newSet.Checksum()
  94. if err != nil {
  95. return fmt.Errorf("failed to checksum new pricing data: %w", err)
  96. }
  97. existingChecksum, err := existingSet.Checksum()
  98. if err != nil {
  99. return fmt.Errorf("failed to checksum existing pricing data: %w", err)
  100. }
  101. if newChecksum != existingChecksum {
  102. fmt.Fprintf(os.Stderr, "pricing drift detected for %s: existing=%s fresh=%s\n", curr, existingChecksum, newChecksum)
  103. os.Exit(2)
  104. }
  105. log.Infof("Pricing data is up to date for %s (checksum: %s)", curr, existingChecksum)
  106. return nil
  107. }
  108. // readPricingJSONL reads nodes.jsonl and persistentvolumes.jsonl from dir.
  109. func readPricingJSONL(dir string) (*pricing.PricingSet, error) {
  110. ps := &pricing.PricingSet{}
  111. nodes, err := readJSONL[*pricing.NodePricing](dir + "/nodes.jsonl")
  112. if err != nil {
  113. return nil, err
  114. }
  115. ps.NodePricing = nodes
  116. pvs, err := readJSONL[*pricing.PersistentVolumePricing](dir + "/persistentvolumes.jsonl")
  117. if err != nil {
  118. return nil, err
  119. }
  120. ps.PersistentVolumePricing = pvs
  121. return ps, nil
  122. }
  123. // readJSONL decodes every line of path into a slice of T.
  124. func readJSONL[T any](path string) ([]T, error) {
  125. f, err := os.Open(path)
  126. if err != nil {
  127. return nil, fmt.Errorf("opening %s: %w", path, err)
  128. }
  129. defer f.Close()
  130. var items []T
  131. dec := json.NewDecoder(f)
  132. for dec.More() {
  133. var item T
  134. if err := dec.Decode(&item); err != nil {
  135. return nil, fmt.Errorf("decoding record from %s: %w", path, err)
  136. }
  137. items = append(items, item)
  138. }
  139. return items, nil
  140. }