infrawrench-bulk_instances 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bccbd61e8ed4801a2ca745978807a8ec752b6b04cc8b5042cf92b01a7406ffeb
4
- data.tar.gz: 529c9965e71e9a194204cfdbba3b78be46ba6779a225d4e580ab83621df2f3db
3
+ metadata.gz: 35025be8d677ed34cdfea296faca613c02d6ffe5c88d35266ad33fc36947d704
4
+ data.tar.gz: 3035dee6a0a3f8f86923ab7fb1b1c16c02fbc0e2aff9798a18d4640b4518138c
5
5
  SHA512:
6
- metadata.gz: a26d838f6e2da662c1dd77bea3f021e509a679fb45b14dd86496d238f2dc71417f85a8c96651d90d2402e0be6244b8919b71e9da1171af308f883ea126f6c896
7
- data.tar.gz: 4045a6ef4cb8451a56de51a4b6215eb0ed2c7f9418b9cfcc7f665fa1e4c221dc1a91b2b80020e79392166e3a8b75ddfc6a86f2723919bbb29e27485e7c271a8a
6
+ metadata.gz: fd9380c0871c15a34ebe92328f868d6347f6a3b6ce3722a137318d44c48b70cc22573fc38d696a9f66584ea604e97feacfeeecd3772bf87d6285ba93167d11fe
7
+ data.tar.gz: 33c0e9e0a4e9855840c34f2a93d4d4903a8f813d495daadf782e0057ba3b9df45f34c2f17699f155a7dd509ad62629cf603f046c07f9f9dea9c1510a684da852
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # infrawrench-bulk_instances
2
2
 
3
- AWS EC2 instance attributes and prices, with their full history, from the bundle
4
- Infrawrench publishes at `instances-bundles.infrawrench.com`.
3
+ AWS instance attributes and prices (EC2, RDS, ElastiCache and more), with their full
4
+ history, from the bundles Infrawrench publishes at `instances-bundles.infrawrench.com`.
5
5
 
6
6
  ```sh
7
7
  gem install infrawrench-bulk_instances
@@ -29,16 +29,69 @@ m5.pricing_history.reserved # every reserved price change, oldest first
29
29
  Any Infrawrench bearer credential works as `token:` (an `iwk_` API key, an agent
30
30
  credential or a WorkOS access token). It defaults to `INFRAWRENCH_API_KEY`.
31
31
 
32
+ ## Other AWS platforms
33
+
34
+ `download_aws_pricing_data(platform, **options)` loads any platform's bundle;
35
+ `download_aws_ec2_pricing_data(**options)` is the same as `download_aws_pricing_data("ec2", **options)`.
36
+ `Infrawrench::BulkInstances::AWS_PLATFORMS` lists the platforms:
37
+
38
+ | Platform | AWS service |
39
+ | --- | --- |
40
+ | `ec2` | EC2 |
41
+ | `rds` | RDS |
42
+ | `elasticache` | ElastiCache |
43
+ | `opensearch` | OpenSearch Service |
44
+ | `redshift` | Redshift |
45
+ | `docdb` | DocumentDB |
46
+ | `neptune` | Neptune |
47
+ | `memorydb` | MemoryDB |
48
+ | `dax` | DynamoDB Accelerator |
49
+ | `sagemaker` | SageMaker |
50
+ | `emr` | EMR |
51
+ | `gamelift` | GameLift |
52
+
53
+ The fields that pick a price differ by platform. `data.variant_keys` names them, and every
54
+ price has a `variant` hash keyed by them:
55
+
56
+ ```ruby
57
+ rds = Infrawrench::BulkInstances.download_aws_pricing_data("rds")
58
+ rds.variant_keys # ["databaseEngine", "databaseEdition", "licenseModel", "deploymentOption", ...]
59
+ rds["db.m5.large"].pricing.on_demand.find do |p|
60
+ p.region_code == "us-east-1" && p.variant["databaseEngine"] == "MySQL" &&
61
+ p.variant["deploymentOption"] == "Multi-AZ"
62
+ end&.hourly
63
+ ```
64
+
65
+ `operating_system`, `tenancy`, `license_model`, `pre_installed_sw` and `capacitystatus` are EC2's
66
+ variant fields. On other platforms they are `""`, unless the platform has a field with that name
67
+ (RDS has `licenseModel`).
68
+
32
69
  Requires Ruby 3.2 or later, and liblzma (part of macOS and of practically every
33
70
  Linux distribution; `xz-utils`/`xz` otherwise) to decompress downloads.
34
71
 
72
+ ## One instance
73
+
74
+ `Infrawrench::BulkInstances.fetch_aws_instance(platform, instance_type, ...)` fetches a single
75
+ instance from the Infrawrench API instead of downloading the platform's whole bundle. It returns
76
+ the same `AWSInstance` that `get` does, or `nil` if the platform has no instance with that name:
77
+
78
+ ```ruby
79
+ m5 = Infrawrench::BulkInstances.fetch_aws_instance("ec2", "m5.large")
80
+ m5&.pricing&.on_demand&.length
81
+ ```
82
+
83
+ It takes `token:` like the bundle download, and `base_url:` for where the API is served from
84
+ (`https://app.infrawrench.com` by default). Nothing is cached. The API syncs each new bundle within
85
+ about half an hour of it being published, so it can briefly trail `download_aws_pricing_data`.
86
+
35
87
  ## Caching
36
88
 
37
89
  The bundle is downloaded xz-compressed (about 20 MB) and kept decompressed (about
38
90
  210 MB) in `~/.infrawrench/aws_pricing_data` (`cache_path:`), so a cached load skips
39
91
  decompression. Each load asks the server which bundle is latest and downloads it
40
92
  only if it is not cached, deleting the older one. If the server cannot be
41
- reached, the newest cached bundle is used instead. Pass `cache_pricing_data: false`
93
+ reached, the newest cached bundle is used instead. Each platform's bundle is cached
94
+ under its own name (`rds-bundle-…json`), so loading one never evicts another. Pass `cache_pricing_data: false`
42
95
  to always download and never touch the disk. The cache is shared with the other
43
96
  Infrawrench SDKs.
44
97
 
@@ -138,7 +138,7 @@ module Infrawrench
138
138
  private
139
139
 
140
140
  def od_key(r)
141
- [r.region, r.operating_system, r.tenancy, r.license_model, r.pre_installed_sw, r.capacitystatus]
141
+ [r.region, *r.variant.values]
142
142
  end
143
143
  end
144
144
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Infrawrench
4
4
  module BulkInstances
5
- # The AWS EC2 pricing data, keyed by instance type.
5
+ # One AWS platform's pricing data, keyed by instance type.
6
6
  #
7
7
  # Nothing is decoded until it is asked for: #get wraps the raw rows, and each
8
8
  # reader on the returned AWSInstance decodes its part on first access and keeps
@@ -15,10 +15,12 @@ module Infrawrench
15
15
  # index into +compression_table+:
16
16
  #
17
17
  # {
18
+ # "offer" => "AmazonRDS", # EC2 bundles leave it out
18
19
  # "compression_table" => [String, ...],
19
20
  # "snapshots" => [Integer, ...],
20
21
  # "regions" => [[location, [[key, value], ...]], ...],
21
- # "variants" => [[os, tenancy, license, sw, capacity], ...],
22
+ # "variant_keys" => [String, ...], # EC2 bundles leave it out (see EC2_VARIANT_KEYS)
23
+ # "variants" => [[value, ...], ...], # one value per variant key
22
24
  # "reserved_terms" => [[lease, offering, purchase], ...],
23
25
  # "instances" => { "m5.large" => [attrs, attr_changes, base_price, on_demand, reserved, created_at, deleted_at] },
24
26
  # }
@@ -50,6 +52,18 @@ module Infrawrench
50
52
  end
51
53
  alias [] get
52
54
 
55
+ # The AWS offer code, e.g. +AmazonEC2+ or +AmazonRDS+.
56
+ # @return [String]
57
+ def offer
58
+ @raw["offer"] || "AmazonEC2"
59
+ end
60
+
61
+ # The names of the fields that pick a price, which every price's +variant+ is keyed by.
62
+ # @return [Array<String>]
63
+ def variant_keys
64
+ @raw["variant_keys"] || EC2_VARIANT_KEYS
65
+ end
66
+
53
67
  # The publication date of every AWS price list in the bundle, oldest first.
54
68
  # @return [Array<Time>]
55
69
  def snapshots
@@ -82,13 +96,18 @@ module Infrawrench
82
96
  # @api private
83
97
  # @return [Variant]
84
98
  def variant(i)
85
- @variants ||= @raw["variants"].map do |os, tenancy, license, sw, capacity|
99
+ @variants ||= @raw["variants"].map do |fields|
100
+ keys = variant_keys
101
+ raise "variant has #{fields.length} fields, expected #{keys.length}" if fields.length != keys.length
102
+
103
+ variant = keys.zip(fields).to_h { |k, v| [k, str(v)] }.freeze
86
104
  Variant.new(
87
- operating_system: str(os),
88
- tenancy: str(tenancy),
89
- license_model: str(license),
90
- pre_installed_sw: str(sw),
91
- capacitystatus: str(capacity),
105
+ variant:,
106
+ operating_system: variant.fetch("operatingSystem", ""),
107
+ tenancy: variant.fetch("tenancy", ""),
108
+ license_model: variant.fetch("licenseModel", ""),
109
+ pre_installed_sw: variant.fetch("preInstalledSw", ""),
110
+ capacitystatus: variant.fetch("capacitystatus", ""),
92
111
  )
93
112
  end.freeze
94
113
  @variants[i]
@@ -2,13 +2,17 @@
2
2
 
3
3
  module Infrawrench
4
4
  module BulkInstances
5
- # Every price and price change starts with its location:
5
+ # Every price and price change starts with its location and variant:
6
6
  #
7
7
  # - +region+: the AWS location name, e.g. <tt>US East (N. Virginia)</tt>.
8
8
  # - +region_code+: e.g. +us-east-1+. +nil+ for locations the price list never gave a code.
9
+ # - +variant+: every field that picks the price, as a frozen <tt>Hash{String => String}</tt> keyed
10
+ # by name in AWSPricingData#variant_keys order: for RDS, +databaseEngine+, +deploymentOption+ and so on.
11
+ # - +operating_system+, +tenancy+, +license_model+, +pre_installed_sw+, +capacitystatus+: EC2's
12
+ # variant fields. For other platforms these are <tt>""</tt> unless the platform shares the field.
9
13
 
10
14
  # The product fields that pick a price.
11
- Variant = Data.define(:operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus)
15
+ Variant = Data.define(:variant, :operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus)
12
16
 
13
17
  ReservedTerm = Data.define(:lease_contract_length, :offering_class, :purchase_option)
14
18
 
@@ -18,7 +22,7 @@ module Infrawrench
18
22
  # @return [Time] When this price took effect.
19
23
  OnDemandPrice = Data.define(
20
24
  :region, :region_code,
21
- :operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus,
25
+ :variant, :operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus,
22
26
  :hourly, :since,
23
27
  )
24
28
 
@@ -30,7 +34,7 @@ module Infrawrench
30
34
  # @return [Time]
31
35
  ReservedPrice = Data.define(
32
36
  :region, :region_code,
33
- :operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus,
37
+ :variant, :operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus,
34
38
  :lease_contract_length, :offering_class, :purchase_option,
35
39
  :hourly, :upfront, :since,
36
40
  )
@@ -47,7 +51,7 @@ module Infrawrench
47
51
  # @return [Float, nil] +nil+ means no longer offered from +at+ on.
48
52
  OnDemandPriceChange = Data.define(
49
53
  :at, :region, :region_code,
50
- :operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus,
54
+ :variant, :operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus,
51
55
  :hourly,
52
56
  )
53
57
 
@@ -59,7 +63,7 @@ module Infrawrench
59
63
  # @return [Float, nil]
60
64
  ReservedPriceChange = Data.define(
61
65
  :at, :region, :region_code,
62
- :operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus,
66
+ :variant, :operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus,
63
67
  :lease_contract_length, :offering_class, :purchase_option,
64
68
  :hourly, :upfront,
65
69
  )
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Infrawrench
4
4
  module BulkInstances
5
- VERSION = "1.0.0"
5
+ VERSION = "1.2.0"
6
6
  end
7
7
  end
@@ -14,9 +14,10 @@ require_relative "bulk_instances/aws_pricing_data"
14
14
  require_relative "bulk_instances/aws_instance"
15
15
 
16
16
  module Infrawrench
17
- # AWS EC2 instance attributes and pricing, with full history, from Infrawrench.
17
+ # AWS instance attributes and pricing (EC2, RDS, ElastiCache and more), with full history, from Infrawrench.
18
18
  module BulkInstances
19
19
  DEFAULT_BASE_URL = "https://instances-bundles.infrawrench.com"
20
+ DEFAULT_API_URL = "https://app.infrawrench.com"
20
21
  USER_AGENT = "infrawrench/bulk-instances, ruby, 1.0"
21
22
 
22
23
  # What Net::HTTP raises when the server cannot be reached or the connection breaks.
@@ -42,12 +43,15 @@ module Infrawrench
42
43
  end
43
44
  private_constant :Util
44
45
 
46
+ # The AWS platforms Infrawrench publishes a bundle for. Any other lowercase name is
47
+ # requested as is, so a platform added later works without a new SDK.
48
+ AWS_PLATFORMS = %w[ec2 rds elasticache opensearch redshift docdb neptune memorydb dax sagemaker emr gamelift].freeze
49
+
50
+ # The variant keys of EC2 bundles, which don't list their own.
51
+ EC2_VARIANT_KEYS = %w[operatingSystem tenancy licenseModel preInstalledSw capacitystatus].freeze
52
+
45
53
  class << self
46
- # Downloads the AWS EC2 pricing data compressed blob and decompresses it.
47
- #
48
- # The bucket's +latest+ object names the newest bundle. With caching on, a bundle
49
- # is only downloaded when +latest+ names one that is not in the cache yet, and if
50
- # +latest+ cannot be fetched at all (offline, outage) the newest cached bundle is used.
54
+ # Downloads the AWS EC2 pricing data. The same as <tt>download_aws_pricing_data("ec2", ...)</tt>.
51
55
  #
52
56
  # @param token [String, nil] Your Infrawrench API token. Defaults to the +INFRAWRENCH_API_KEY+ environment variable.
53
57
  # @param cache_pricing_data [Boolean] Whether to cache the pricing data.
@@ -56,6 +60,29 @@ module Infrawrench
56
60
  # @return [AWSPricingData]
57
61
  # @raise [AWSPricingDataError]
58
62
  def download_aws_ec2_pricing_data(token: nil, cache_pricing_data: true, cache_path: nil, base_url: nil)
63
+ download_aws_pricing_data("ec2", token:, cache_pricing_data:, cache_path:, base_url:)
64
+ end
65
+
66
+ # Downloads one AWS platform's pricing data compressed blob and decompresses it.
67
+ #
68
+ # The bucket's <tt><platform>-latest</tt> object (+latest+ for EC2) names the newest bundle. With
69
+ # caching on, a bundle is only downloaded when that names one that is not in the cache yet,
70
+ # and if it cannot be fetched at all (offline, outage) the newest cached bundle is used.
71
+ #
72
+ # @param platform [String, Symbol] One of AWS_PLATFORMS, e.g. +"rds"+, or any other lowercase platform name.
73
+ # @param token [String, nil] Your Infrawrench API token. Defaults to the +INFRAWRENCH_API_KEY+ environment variable.
74
+ # @param cache_pricing_data [Boolean] Whether to cache the pricing data.
75
+ # @param cache_path [String, nil] The cache path. Defaults to <tt>~/.infrawrench/aws_pricing_data</tt>.
76
+ # @param base_url [String, nil] Where the bundles are served from. Defaults to https://instances-bundles.infrawrench.com
77
+ # @return [AWSPricingData]
78
+ # @raise [AWSPricingDataError]
79
+ def download_aws_pricing_data(platform, token: nil, cache_pricing_data: true, cache_path: nil, base_url: nil)
80
+ unless (platform.is_a?(String) || platform.is_a?(Symbol)) && platform.match?(/\A[a-z0-9]+\z/)
81
+ raise AWSPricingDataError, %("#{platform}" is not a platform name.)
82
+ end
83
+
84
+ # EC2's keys have no prefix because they were published before the other platforms.
85
+ prefix = platform.to_s == "ec2" ? "" : "#{platform}-"
59
86
  token = ENV.fetch("INFRAWRENCH_API_KEY", nil) if token.nil?
60
87
  if token.nil? || token.empty?
61
88
  raise AWSPricingDataError, "No Infrawrench API token: pass `token:` or set INFRAWRENCH_API_KEY."
@@ -65,15 +92,15 @@ module Infrawrench
65
92
  cache_path = (cache_path || File.join(Dir.home, ".infrawrench", "aws_pricing_data")).to_s
66
93
 
67
94
  begin
68
- latest = download("#{base_url}/latest", token, &:read_body).to_s.dup.force_encoding(Encoding::UTF_8).scrub.strip
95
+ latest = download("#{base_url}/#{prefix}latest", token, &:read_body).to_s.dup.force_encoding(Encoding::UTF_8).scrub.strip
69
96
  unless latest.match?(/\A[A-Za-z0-9._-]+\z/)
70
- raise AWSPricingDataError, %(latest holds "#{latest}", which is not a timestamp.)
97
+ raise AWSPricingDataError, %(#{prefix}latest holds "#{latest}", which is not a timestamp.)
71
98
  end
72
99
 
73
- name = "bundle-#{latest}.json"
100
+ name = "#{prefix}bundle-#{latest}.json"
74
101
  rescue StandardError => e
75
102
  # A rejected token is a real answer; anything else falls back to what we already have.
76
- cached = newest_cached(cache_path) if cache_pricing_data && !(e.is_a?(AWSPricingDataError) && e.status == 401)
103
+ cached = newest_cached(cache_path, prefix) if cache_pricing_data && !(e.is_a?(AWSPricingDataError) && e.status == 401)
77
104
  raise if cached.nil?
78
105
 
79
106
  name = cached
@@ -92,9 +119,9 @@ module Infrawrench
92
119
  tmp = File.join(cache_path, "#{name}.#{Process.pid}.tmp")
93
120
  File.binwrite(tmp, json)
94
121
  File.rename(tmp, File.join(cache_path, name))
95
- # Older bundles, including compressed ones cached by earlier versions of this SDK.
122
+ # This platform's older bundles, including compressed ones cached by earlier versions of this SDK.
96
123
  Dir.each_child(cache_path) do |f|
97
- FileUtils.rm_f(File.join(cache_path, f)) if f != name && f.match?(/\Abundle-.+\.json(\.xz)?\z/)
124
+ FileUtils.rm_f(File.join(cache_path, f)) if f != name && f.match?(/\A#{prefix}bundle-.+\.json(\.xz)?\z/)
98
125
  end
99
126
  end
100
127
  end
@@ -102,10 +129,53 @@ module Infrawrench
102
129
  AWSPricingData.new(JSON.parse(json, freeze: true))
103
130
  end
104
131
 
132
+ # Fetches one instance from the Infrawrench API, without downloading the platform's bundle.
133
+ # Nothing is cached.
134
+ #
135
+ # The result is the same as <tt>download_aws_pricing_data(platform).get(instance_type)</tt>, from
136
+ # whichever bundle the API last synced, which can trail the newest bundle by up to half an hour.
137
+ #
138
+ # @param platform [String, Symbol] One of AWS_PLATFORMS, e.g. +"rds"+, or any other lowercase platform name.
139
+ # @param instance_type [String] The instance type, e.g. +"m5.large"+.
140
+ # @param token [String, nil] Your Infrawrench API token. Defaults to the +INFRAWRENCH_API_KEY+ environment variable.
141
+ # @param base_url [String, nil] Where the Infrawrench API is served from. Defaults to https://app.infrawrench.com
142
+ # @return [AWSInstance, nil] +nil+ if the platform has no instance with that name.
143
+ # @raise [AWSPricingDataError]
144
+ def fetch_aws_instance(platform, instance_type, token: nil, base_url: nil)
145
+ unless (platform.is_a?(String) || platform.is_a?(Symbol)) && platform.match?(/\A[a-z0-9]+\z/)
146
+ raise AWSPricingDataError, %("#{platform}" is not a platform name.)
147
+ end
148
+ raise AWSPricingDataError, "The instance type is empty." if instance_type.to_s.empty?
149
+
150
+ token = ENV.fetch("INFRAWRENCH_API_KEY", nil) if token.nil?
151
+ if token.nil? || token.empty?
152
+ raise AWSPricingDataError, "No Infrawrench API token: pass `token:` or set INFRAWRENCH_API_KEY."
153
+ end
154
+
155
+ base_url = (base_url || DEFAULT_API_URL).sub(%r{/+\z}, "")
156
+ # Percent-encoded, with spaces as %20: a + in a path is a plus.
157
+ name = URI.encode_www_form_component(instance_type.to_s).gsub("+", "%20")
158
+ begin
159
+ json = download("#{base_url}/api/v1/aws-bulk-pricing/#{platform}/#{name}", token, &:read_body)
160
+ rescue AWSPricingDataError => e
161
+ return nil if e.status == 404
162
+
163
+ raise
164
+ end
165
+
166
+ # The bundle's tables cut down to what this instance uses, and its row renumbered to match.
167
+ value = JSON.parse(json.to_s.dup.force_encoding(Encoding::UTF_8), freeze: true)
168
+ bundle = value.slice("offer", "compression_table", "regions", "variant_keys", "variants", "reserved_terms")
169
+ bundle["snapshots"] = []
170
+ bundle["instances"] = { value["name"] => value["instance"] }
171
+ AWSPricingData.new(bundle).get(value["name"])
172
+ end
173
+
105
174
  private
106
175
 
107
176
  # GETs +url+ and yields the successful response, retrying the 503 the bundles Worker
108
- # answers when it cannot reach the Infrawrench API.
177
+ # answers when it cannot reach the Infrawrench API (and the API answers when it cannot
178
+ # reach its store).
109
179
  def download(url, token)
110
180
  uri = URI(url)
111
181
  (1..).each do |attempt|
@@ -135,9 +205,9 @@ module Infrawrench
135
205
  raise AWSPricingDataError, "GET #{url} failed: #{e.message}"
136
206
  end
137
207
 
138
- # The newest bundle already in the cache directory, if any.
139
- def newest_cached(cache_path)
140
- Dir.children(cache_path).grep(/\Abundle-.+\.json\z/).max
208
+ # The newest bundle for a platform already in the cache directory, if any.
209
+ def newest_cached(cache_path, prefix)
210
+ Dir.children(cache_path).grep(/\A#{prefix}bundle-.+\.json\z/).max
141
211
  rescue SystemCallError
142
212
  nil
143
213
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: infrawrench-bulk_instances
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Infrawrench LLC
@@ -61,5 +61,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
61
61
  requirements: []
62
62
  rubygems_version: 3.6.9
63
63
  specification_version: 4
64
- summary: AWS EC2 instance attributes and pricing, with full history, from Infrawrench.
64
+ summary: AWS instance attributes and pricing (EC2, RDS, ElastiCache and more), with
65
+ full history, from Infrawrench.
65
66
  test_files: []