main.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  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, %d volume pricing entries, %d service pricing entries",
  47. len(pricingSet.NodePricing), len(pricingSet.PersistentVolumePricing), len(pricingSet.ServicePricing))
  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. if err := writeJSONL(dir+"/services.jsonl", ps.ServicePricing); err != nil {
  63. return err
  64. }
  65. return nil
  66. }
  67. // writeJSONL marshals each item in items as a single line and writes them to path.
  68. func writeJSONL[T any](path string, items []T) error {
  69. f, err := os.Create(path)
  70. if err != nil {
  71. return fmt.Errorf("creating %s: %w", path, err)
  72. }
  73. defer f.Close()
  74. w := bufio.NewWriter(f)
  75. enc := json.NewEncoder(w) // Encode appends a trailing newline after each value.
  76. for _, item := range items {
  77. if err := enc.Encode(item); err != nil {
  78. return fmt.Errorf("encoding record to %s: %w", path, err)
  79. }
  80. }
  81. if err := w.Flush(); err != nil {
  82. return fmt.Errorf("flushing %s: %w", path, err)
  83. }
  84. log.Infof("Wrote %d records to %s", len(items), path)
  85. return nil
  86. }
  87. // comparePricing compares a fresh pricing set against the existing JSONL files
  88. // for a given currency.
  89. func comparePricing(curr unit.Currency, newSet *pricing.PricingSet) error {
  90. dir := fmt.Sprintf("%s/%s", outputDir, strings.ToLower(string(curr)))
  91. existingSet, err := readPricingJSONL(dir)
  92. if err != nil {
  93. return fmt.Errorf("reading existing pricing data from %s: %w", dir, err)
  94. }
  95. existingSet.Sort()
  96. newChecksum, err := newSet.Checksum()
  97. if err != nil {
  98. return fmt.Errorf("failed to checksum new pricing data: %w", err)
  99. }
  100. existingChecksum, err := existingSet.Checksum()
  101. if err != nil {
  102. return fmt.Errorf("failed to checksum existing pricing data: %w", err)
  103. }
  104. if newChecksum != existingChecksum {
  105. fmt.Fprintf(os.Stderr, "pricing drift detected for %s: existing=%s fresh=%s\n", curr, existingChecksum, newChecksum)
  106. os.Exit(2)
  107. }
  108. log.Infof("Pricing data is up to date for %s (checksum: %s)", curr, existingChecksum)
  109. return nil
  110. }
  111. // readPricingJSONL reads nodes.jsonl and persistentvolumes.jsonl from dir.
  112. func readPricingJSONL(dir string) (*pricing.PricingSet, error) {
  113. ps := &pricing.PricingSet{}
  114. nodes, err := readJSONL[*pricing.NodePricing](dir + "/nodes.jsonl")
  115. if err != nil {
  116. return nil, err
  117. }
  118. ps.NodePricing = nodes
  119. pvs, err := readJSONL[*pricing.PersistentVolumePricing](dir + "/persistentvolumes.jsonl")
  120. if err != nil {
  121. return nil, err
  122. }
  123. ps.PersistentVolumePricing = pvs
  124. svcs, err := readJSONL[*pricing.ServicePricing](dir + "/services.jsonl")
  125. if err != nil {
  126. return nil, err
  127. }
  128. ps.ServicePricing = svcs
  129. return ps, nil
  130. }
  131. // readJSONL decodes every line of path into a slice of T.
  132. func readJSONL[T any](path string) ([]T, error) {
  133. f, err := os.Open(path)
  134. if err != nil {
  135. return nil, fmt.Errorf("opening %s: %w", path, err)
  136. }
  137. defer f.Close()
  138. var items []T
  139. dec := json.NewDecoder(f)
  140. for dec.More() {
  141. var item T
  142. if err := dec.Decode(&item); err != nil {
  143. return nil, fmt.Errorf("decoding record from %s: %w", path, err)
  144. }
  145. items = append(items, item)
  146. }
  147. return items, nil
  148. }