infrawrench-bulk_instances 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bccbd61e8ed4801a2ca745978807a8ec752b6b04cc8b5042cf92b01a7406ffeb
4
+ data.tar.gz: 529c9965e71e9a194204cfdbba3b78be46ba6779a225d4e580ab83621df2f3db
5
+ SHA512:
6
+ metadata.gz: a26d838f6e2da662c1dd77bea3f021e509a679fb45b14dd86496d238f2dc71417f85a8c96651d90d2402e0be6244b8919b71e9da1171af308f883ea126f6c896
7
+ data.tar.gz: 4045a6ef4cb8451a56de51a4b6215eb0ed2c7f9418b9cfcc7f665fa1e4c221dc1a91b2b80020e79392166e3a8b75ddfc6a86f2723919bbb29e27485e7c271a8a
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Infrawrench LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # infrawrench-bulk_instances
2
+
3
+ AWS EC2 instance attributes and prices, with their full history, from the bundle
4
+ Infrawrench publishes at `instances-bundles.infrawrench.com`.
5
+
6
+ ```sh
7
+ gem install infrawrench-bulk_instances
8
+ ```
9
+
10
+ ```ruby
11
+ require "infrawrench/bulk_instances"
12
+
13
+ data = Infrawrench::BulkInstances.download_aws_ec2_pricing_data(token: ENV["INFRAWRENCH_API_KEY"])
14
+
15
+ data.keys # ["m5.large", "t2.micro", ...]
16
+
17
+ m5 = data["m5.large"]
18
+ m5.attributes["vcpu"] # "2"
19
+ m5.attributes_in("US East (N. Virginia)") # with that region's overrides
20
+ m5.pricing.on_demand.find do |p|
21
+ p.region_code == "us-east-1" && p.operating_system == "Linux" && p.tenancy == "Shared" &&
22
+ p.pre_installed_sw == "NA" && p.capacitystatus == "Used"
23
+ end&.hourly # 0.096
24
+
25
+ m5.attributes_history # every attribute change, oldest first
26
+ m5.pricing_history.reserved # every reserved price change, oldest first
27
+ ```
28
+
29
+ Any Infrawrench bearer credential works as `token:` (an `iwk_` API key, an agent
30
+ credential or a WorkOS access token). It defaults to `INFRAWRENCH_API_KEY`.
31
+
32
+ Requires Ruby 3.2 or later, and liblzma (part of macOS and of practically every
33
+ Linux distribution; `xz-utils`/`xz` otherwise) to decompress downloads.
34
+
35
+ ## Caching
36
+
37
+ The bundle is downloaded xz-compressed (about 20 MB) and kept decompressed (about
38
+ 210 MB) in `~/.infrawrench/aws_pricing_data` (`cache_path:`), so a cached load skips
39
+ decompression. Each load asks the server which bundle is latest and downloads it
40
+ 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`
42
+ to always download and never touch the disk. The cache is shared with the other
43
+ Infrawrench SDKs.
44
+
45
+ Loading decompresses and parses the whole bundle. Instances are decoded lazily:
46
+ `get` only wraps the raw rows, and `attributes`, `attributes_history`, `pricing`
47
+ and `pricing_history` each decode on first access and keep the result. The
48
+ returned objects are shared between calls, so they are frozen. Memoization is not
49
+ synchronized: threads racing on a first access may decode the same part twice,
50
+ which is harmless.
51
+
52
+ Failures raise `Infrawrench::BulkInstances::AWSPricingDataError`, whose `status`
53
+ is the HTTP status if there was a response.
54
+
55
+ ## Prices and regions
56
+
57
+ - `region` is the AWS location name (`US East (N. Virginia)`), `region_code` its
58
+ code (`us-east-1`) where the price list gives one, else `nil`.
59
+ - `pricing` holds only what is still offered. In `pricing_history`, a `nil`
60
+ `hourly` means the price stopped being offered at `at`.
61
+ - Times are UTC `Time`s.
62
+ - Instances AWS no longer lists are still in `keys`, with `deleted_at` set and
63
+ empty `pricing`.
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infrawrench
4
+ module BulkInstances
5
+ # One instance type. Each reader decodes on first access and caches the result.
6
+ class AWSInstance
7
+ # The instance type, e.g. +m5.large+.
8
+ # @return [String]
9
+ attr_reader :name
10
+
11
+ def initialize(name, raw, data)
12
+ @name = name
13
+ @raw = raw
14
+ @data = data
15
+ end
16
+
17
+ # The publication date of the first price list that listed this instance.
18
+ # @return [Time]
19
+ def created_at
20
+ @created_at ||= Util.time(@raw[5])
21
+ end
22
+
23
+ # The publication date of the first price list that stopped listing it, or +nil+ if AWS still lists it.
24
+ # @return [Time, nil]
25
+ def deleted_at
26
+ return if @raw[6].nil?
27
+
28
+ @deleted_at ||= Util.time(@raw[6])
29
+ end
30
+
31
+ # The latest attributes shared by every region. Regional differences are in #attributes_in.
32
+ # @return [Hash{String => String}]
33
+ def attributes
34
+ @attributes ||= attributes_history.each_with_object({}) do |c, out|
35
+ next unless c.region.nil?
36
+
37
+ if c.value.nil?
38
+ out.delete(c.key)
39
+ else
40
+ out[c.key] = c.value
41
+ end
42
+ end.freeze
43
+ end
44
+
45
+ # The latest attributes in one AWS location (e.g. <tt>US East (N. Virginia)</tt>): the
46
+ # global ones with that region's overrides on top. A regional override keeps
47
+ # applying even after a newer global change to the same key.
48
+ # @return [Hash{String => String}]
49
+ def attributes_in(region)
50
+ out = attributes.dup
51
+ overrides = {}
52
+ attributes_history.each { |c| overrides[c.key] = c.value if c.region == region }
53
+ overrides.each do |k, v|
54
+ if v.nil?
55
+ out.delete(k)
56
+ else
57
+ out[k] = v
58
+ end
59
+ end
60
+ out.freeze
61
+ end
62
+
63
+ # Every attribute change, oldest first, starting with the attributes the instance was first listed with.
64
+ # @return [Array<AttributeChange>]
65
+ def attributes_history
66
+ @attributes_history ||= begin
67
+ d = @data
68
+ initial = @raw[0].map do |k, v|
69
+ AttributeChange.new(at: created_at, key: d.str(k), value: d.str(v), region: nil)
70
+ end
71
+ changes = Util.sort_by_time(@raw[1]).map do |ts, key, value, region|
72
+ AttributeChange.new(
73
+ at: Util.time(ts),
74
+ key: d.str(key),
75
+ value: value.nil? ? nil : d.str(value),
76
+ region: region.nil? ? nil : d.str(region),
77
+ )
78
+ end
79
+ (initial + changes).freeze
80
+ end
81
+ end
82
+
83
+ # The current price of everything still offered.
84
+ # @return [Pricing]
85
+ def pricing
86
+ @pricing ||= begin
87
+ history = pricing_history
88
+ od = {}
89
+ history.on_demand.each { |r| od[od_key(r)] = r }
90
+ rs = {}
91
+ history.reserved.each do |r|
92
+ rs[[*od_key(r), r.lease_contract_length, r.offering_class, r.purchase_option]] = r
93
+ end
94
+
95
+ Pricing.new(
96
+ on_demand: od.each_value.reject { |r| r.hourly.nil? }.map do |r|
97
+ OnDemandPrice.new(**r.to_h.except(:at), since: r.at)
98
+ end.freeze,
99
+ reserved: rs.each_value.reject { |r| r.hourly.nil? }.map do |r|
100
+ ReservedPrice.new(**r.to_h.except(:at, :upfront), upfront: r.upfront || 0.0, since: r.at)
101
+ end.freeze,
102
+ )
103
+ end
104
+ end
105
+
106
+ # Every on-demand and reserved price change, oldest first.
107
+ # @return [PricingHistory]
108
+ def pricing_history
109
+ @pricing_history ||= begin
110
+ d = @data
111
+ base = @raw[2].to_f
112
+ PricingHistory.new(
113
+ # [ts, region, variant, price: omitted = base price, null = no longer offered]
114
+ on_demand: Util.sort_by_time(@raw[3]).map do |ts, region, variant, *price|
115
+ OnDemandPriceChange.new(
116
+ at: Util.time(ts),
117
+ **d.location(region),
118
+ **d.variant(variant).to_h,
119
+ hourly: price.empty? ? base : price[0]&.to_f,
120
+ )
121
+ end.freeze,
122
+ # [ts, region, variant, term, hourly: null = no longer offered, upfront: omitted if 0]
123
+ reserved: Util.sort_by_time(@raw[4]).map do |ts, region, variant, term, hourly, upfront|
124
+ removed = hourly.nil?
125
+ ReservedPriceChange.new(
126
+ at: Util.time(ts),
127
+ **d.location(region),
128
+ **d.variant(variant).to_h,
129
+ **d.term(term).to_h,
130
+ hourly: removed ? nil : hourly.to_f,
131
+ upfront: removed ? nil : (upfront || 0).to_f,
132
+ )
133
+ end.freeze,
134
+ )
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ def od_key(r)
141
+ [r.region, r.operating_system, r.tenancy, r.license_model, r.pre_installed_sw, r.capacitystatus]
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infrawrench
4
+ module BulkInstances
5
+ # The AWS EC2 pricing data, keyed by instance type.
6
+ #
7
+ # Nothing is decoded until it is asked for: #get wraps the raw rows, and each
8
+ # reader on the returned AWSInstance decodes its part on first access and keeps
9
+ # the result. Everything returned is frozen and shared between calls.
10
+ #
11
+ # Memoization is not synchronized: threads racing on a first access may decode
12
+ # the same part twice, which is harmless.
13
+ class AWSPricingData
14
+ # +raw+ is the parsed bundle as instances-bundler writes it, where every string is an
15
+ # index into +compression_table+:
16
+ #
17
+ # {
18
+ # "compression_table" => [String, ...],
19
+ # "snapshots" => [Integer, ...],
20
+ # "regions" => [[location, [[key, value], ...]], ...],
21
+ # "variants" => [[os, tenancy, license, sw, capacity], ...],
22
+ # "reserved_terms" => [[lease, offering, purchase], ...],
23
+ # "instances" => { "m5.large" => [attrs, attr_changes, base_price, on_demand, reserved, created_at, deleted_at] },
24
+ # }
25
+ def initialize(raw)
26
+ @raw = raw
27
+ @instances = {}
28
+ end
29
+
30
+ # Every instance type in the bundle, including ones AWS no longer lists (see AWSInstance#deleted_at).
31
+ # @return [Array<String>]
32
+ def keys
33
+ @keys ||= @raw["instances"].keys.freeze
34
+ end
35
+
36
+ # @return [Boolean]
37
+ def has?(instance_type)
38
+ @raw["instances"].key?(instance_type)
39
+ end
40
+ alias key? has?
41
+
42
+ # The instance with this type (e.g. +m5.large+), or +nil+ if the bundle has never listed it.
43
+ # @return [AWSInstance, nil]
44
+ def get(instance_type)
45
+ inst = @instances[instance_type]
46
+ if inst.nil? && has?(instance_type)
47
+ inst = @instances[instance_type] = AWSInstance.new(instance_type, @raw["instances"][instance_type], self)
48
+ end
49
+ inst
50
+ end
51
+ alias [] get
52
+
53
+ # The publication date of every AWS price list in the bundle, oldest first.
54
+ # @return [Array<Time>]
55
+ def snapshots
56
+ @snapshots ||= @raw["snapshots"].map { |ts| Util.time(ts) }.freeze
57
+ end
58
+
59
+ # Attributes of each AWS location (+regionCode+, +locationType+), keyed by location name.
60
+ # @return [Hash{String => Hash{String => String}}]
61
+ def regions
62
+ @regions ||= @raw["regions"].to_h do |loc, pairs|
63
+ [str(loc), pairs.to_h { |k, v| [str(k), str(v)] }.freeze]
64
+ end.freeze
65
+ end
66
+
67
+ # @api private
68
+ def str(i)
69
+ s = @raw["compression_table"][i] if i.is_a?(Integer) && i >= 0
70
+ raise IndexError, "string index #{i.inspect} out of range" if s.nil?
71
+
72
+ s
73
+ end
74
+
75
+ # @api private
76
+ # @return [Hash] +region:+ and +region_code:+
77
+ def location(i)
78
+ region = str(i)
79
+ { region:, region_code: regions.dig(region, "regionCode") }
80
+ end
81
+
82
+ # @api private
83
+ # @return [Variant]
84
+ def variant(i)
85
+ @variants ||= @raw["variants"].map do |os, tenancy, license, sw, capacity|
86
+ 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),
92
+ )
93
+ end.freeze
94
+ @variants[i]
95
+ end
96
+
97
+ # @api private
98
+ # @return [ReservedTerm]
99
+ def term(i)
100
+ @terms ||= @raw["reserved_terms"].map do |lease, offering, purchase|
101
+ ReservedTerm.new(lease_contract_length: str(lease), offering_class: str(offering), purchase_option: str(purchase))
102
+ end.freeze
103
+ @terms[i]
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infrawrench
4
+ module BulkInstances
5
+ # Raised when the pricing data cannot be downloaded. +status+ is the HTTP status, if there was a response.
6
+ class AWSPricingDataError < StandardError
7
+ # @return [Integer, nil]
8
+ attr_reader :status
9
+
10
+ def initialize(message = nil, status: nil)
11
+ super(message)
12
+ @status = status
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infrawrench
4
+ module BulkInstances
5
+ # Every price and price change starts with its location:
6
+ #
7
+ # - +region+: the AWS location name, e.g. <tt>US East (N. Virginia)</tt>.
8
+ # - +region_code+: e.g. +us-east-1+. +nil+ for locations the price list never gave a code.
9
+
10
+ # The product fields that pick a price.
11
+ Variant = Data.define(:operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus)
12
+
13
+ ReservedTerm = Data.define(:lease_contract_length, :offering_class, :purchase_option)
14
+
15
+ # @!attribute [r] hourly
16
+ # @return [Float] USD per hour.
17
+ # @!attribute [r] since
18
+ # @return [Time] When this price took effect.
19
+ OnDemandPrice = Data.define(
20
+ :region, :region_code,
21
+ :operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus,
22
+ :hourly, :since,
23
+ )
24
+
25
+ # @!attribute [r] hourly
26
+ # @return [Float] USD per hour.
27
+ # @!attribute [r] upfront
28
+ # @return [Float] USD paid up front.
29
+ # @!attribute [r] since
30
+ # @return [Time]
31
+ ReservedPrice = Data.define(
32
+ :region, :region_code,
33
+ :operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus,
34
+ :lease_contract_length, :offering_class, :purchase_option,
35
+ :hourly, :upfront, :since,
36
+ )
37
+
38
+ # @!attribute [r] on_demand
39
+ # @return [Array<OnDemandPrice>]
40
+ # @!attribute [r] reserved
41
+ # @return [Array<ReservedPrice>]
42
+ Pricing = Data.define(:on_demand, :reserved)
43
+
44
+ # @!attribute [r] at
45
+ # @return [Time]
46
+ # @!attribute [r] hourly
47
+ # @return [Float, nil] +nil+ means no longer offered from +at+ on.
48
+ OnDemandPriceChange = Data.define(
49
+ :at, :region, :region_code,
50
+ :operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus,
51
+ :hourly,
52
+ )
53
+
54
+ # @!attribute [r] at
55
+ # @return [Time]
56
+ # @!attribute [r] hourly
57
+ # @return [Float, nil] +nil+ means no longer offered from +at+ on.
58
+ # @!attribute [r] upfront
59
+ # @return [Float, nil]
60
+ ReservedPriceChange = Data.define(
61
+ :at, :region, :region_code,
62
+ :operating_system, :tenancy, :license_model, :pre_installed_sw, :capacitystatus,
63
+ :lease_contract_length, :offering_class, :purchase_option,
64
+ :hourly, :upfront,
65
+ )
66
+
67
+ # @!attribute [r] on_demand
68
+ # @return [Array<OnDemandPriceChange>] Oldest first.
69
+ # @!attribute [r] reserved
70
+ # @return [Array<ReservedPriceChange>] Oldest first.
71
+ PricingHistory = Data.define(:on_demand, :reserved)
72
+
73
+ # @!attribute [r] at
74
+ # @return [Time]
75
+ # @!attribute [r] key
76
+ # @return [String]
77
+ # @!attribute [r] value
78
+ # @return [String, nil] +nil+ means the attribute was removed.
79
+ # @!attribute [r] region
80
+ # @return [String, nil] The AWS location this change applies to, or +nil+ for every region.
81
+ AttributeChange = Data.define(:at, :key, :value, :region)
82
+ end
83
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infrawrench
4
+ module BulkInstances
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+ require "fiddle/import"
5
+
6
+ module Infrawrench
7
+ module BulkInstances
8
+ # @api private
9
+ #
10
+ # The few liblzma calls needed to decompress an .xz stream, through Fiddle. liblzma
11
+ # ships with macOS and practically every Linux distribution; this file is only
12
+ # loaded when a bundle has to be downloaded, so cached loads never need it.
13
+ module XZ
14
+ extend Fiddle::Importer
15
+
16
+ LIBRARIES = %w[liblzma.so.5 liblzma.5.dylib liblzma.so liblzma.dylib liblzma.dll lzma.dll].freeze
17
+
18
+ def self.open_library
19
+ LIBRARIES.each do |name|
20
+ return Fiddle::Handle.new(name)
21
+ rescue Fiddle::DLError
22
+ next
23
+ end
24
+ raise Fiddle::DLError, "liblzma not found (tried #{LIBRARIES.join(", ")}): install xz to download bundles"
25
+ end
26
+ private_class_method :open_library
27
+
28
+ dlload open_library
29
+
30
+ # lzma_stream, unchanged since liblzma 5.0.
31
+ Stream = struct([
32
+ "const uint8_t *next_in", "size_t avail_in", "uint64_t total_in",
33
+ "uint8_t *next_out", "size_t avail_out", "uint64_t total_out",
34
+ "void *allocator", "void *internal",
35
+ "void *reserved_ptr1", "void *reserved_ptr2", "void *reserved_ptr3", "void *reserved_ptr4",
36
+ "uint64_t seek_pos", "uint64_t reserved_int2", "size_t reserved_int3", "size_t reserved_int4",
37
+ "int reserved_enum1", "int reserved_enum2",
38
+ ])
39
+
40
+ extern "int lzma_stream_decoder(void *, uint64_t, uint32_t)"
41
+ extern "int lzma_code(void *, int)"
42
+ extern "void lzma_end(void *)"
43
+
44
+ OK = 0
45
+ STREAM_END = 1
46
+ RUN = 0
47
+ FINISH = 3
48
+ CONCATENATED = 0x08
49
+ NO_MEMLIMIT = 2**64 - 1
50
+ BUFFER_SIZE = 1 << 20
51
+
52
+ ERRORS = {
53
+ 5 => "out of memory", 7 => "not in the .xz format", 8 => "unsupported options",
54
+ 9 => "corrupt data", 10 => "truncated data",
55
+ }.freeze
56
+
57
+ # Decompresses the .xz data the block writes with +<<+ and returns it as a UTF-8 String.
58
+ def self.decompress
59
+ decoder = Decoder.new
60
+ yield decoder
61
+ decoder.finish
62
+ ensure
63
+ decoder&.close
64
+ end
65
+
66
+ # Feeds the input through malloc'd buffers, so liblzma, which runs without the
67
+ # GVL, never points into Ruby memory.
68
+ class Decoder
69
+ def initialize
70
+ @in = Fiddle::Pointer.malloc(BUFFER_SIZE, Fiddle::RUBY_FREE)
71
+ @out = Fiddle::Pointer.malloc(BUFFER_SIZE, Fiddle::RUBY_FREE)
72
+ ptr = Fiddle::Pointer.malloc(Stream.size, Fiddle::RUBY_FREE)
73
+ ptr[0, Stream.size] = "\0" * Stream.size # LZMA_STREAM_INIT
74
+ @stream = Stream.new(ptr)
75
+ check(XZ.lzma_stream_decoder(@stream, NO_MEMLIMIT, CONCATENATED))
76
+ @result = String.new(encoding: Encoding::BINARY)
77
+ end
78
+
79
+ def <<(chunk)
80
+ (0...chunk.bytesize).step(BUFFER_SIZE) do |offset|
81
+ piece = chunk.byteslice(offset, BUFFER_SIZE)
82
+ @in[0, piece.bytesize] = piece
83
+ code(piece.bytesize, RUN)
84
+ end
85
+ self
86
+ end
87
+
88
+ def finish
89
+ code(0, FINISH)
90
+ @result.force_encoding(Encoding::UTF_8)
91
+ end
92
+
93
+ def close
94
+ XZ.lzma_end(@stream) if @stream
95
+ @stream = nil
96
+ end
97
+
98
+ private
99
+
100
+ def code(size, action)
101
+ @stream.next_in = @in
102
+ @stream.avail_in = size
103
+ loop do
104
+ @stream.next_out = @out
105
+ @stream.avail_out = BUFFER_SIZE
106
+ ret = XZ.lzma_code(@stream, action)
107
+ produced = BUFFER_SIZE - @stream.avail_out
108
+ @result << @out[0, produced] if produced.positive?
109
+ return if ret == STREAM_END
110
+ check(ret)
111
+ # RUN is done once the input is consumed and liblzma had output space to spare.
112
+ # FINISH runs until STREAM_END; liblzma reports truncated input as an error.
113
+ return if action == RUN && @stream.avail_in.zero? && @stream.avail_out.positive?
114
+ end
115
+ end
116
+
117
+ def check(ret)
118
+ return if ret == OK
119
+
120
+ raise AWSPricingDataError, "Could not decompress the bundle: #{ERRORS.fetch(ret, "liblzma error #{ret}")}."
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "uri"
8
+ require "zlib"
9
+
10
+ require_relative "bulk_instances/version"
11
+ require_relative "bulk_instances/error"
12
+ require_relative "bulk_instances/types"
13
+ require_relative "bulk_instances/aws_pricing_data"
14
+ require_relative "bulk_instances/aws_instance"
15
+
16
+ module Infrawrench
17
+ # AWS EC2 instance attributes and pricing, with full history, from Infrawrench.
18
+ module BulkInstances
19
+ DEFAULT_BASE_URL = "https://instances-bundles.infrawrench.com"
20
+ USER_AGENT = "infrawrench/bulk-instances, ruby, 1.0"
21
+
22
+ # What Net::HTTP raises when the server cannot be reached or the connection breaks.
23
+ TRANSPORT_ERRORS = [
24
+ SystemCallError, SocketError, IOError, Timeout::Error, OpenSSL::SSL::SSLError,
25
+ Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::Error,
26
+ ].freeze
27
+ private_constant :TRANSPORT_ERRORS
28
+
29
+ # Helpers shared by the decoders.
30
+ module Util
31
+ module_function
32
+
33
+ def time(ts)
34
+ Time.at(ts, in: "UTC").freeze
35
+ end
36
+
37
+ # Sorts rows by their first element (a timestamp). +sort_by+ is not stable, so the
38
+ # index keeps rows with the same timestamp in order (later rows win).
39
+ def sort_by_time(rows)
40
+ rows.each_with_index.sort_by { |row, i| [row[0], i] }.map(&:first)
41
+ end
42
+ end
43
+ private_constant :Util
44
+
45
+ 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.
51
+ #
52
+ # @param token [String, nil] Your Infrawrench API token. Defaults to the +INFRAWRENCH_API_KEY+ environment variable.
53
+ # @param cache_pricing_data [Boolean] Whether to cache the pricing data.
54
+ # @param cache_path [String, nil] The cache path. Defaults to <tt>~/.infrawrench/aws_pricing_data</tt>.
55
+ # @param base_url [String, nil] Where the bundles are served from. Defaults to https://instances-bundles.infrawrench.com
56
+ # @return [AWSPricingData]
57
+ # @raise [AWSPricingDataError]
58
+ def download_aws_ec2_pricing_data(token: nil, cache_pricing_data: true, cache_path: nil, base_url: nil)
59
+ token = ENV.fetch("INFRAWRENCH_API_KEY", nil) if token.nil?
60
+ if token.nil? || token.empty?
61
+ raise AWSPricingDataError, "No Infrawrench API token: pass `token:` or set INFRAWRENCH_API_KEY."
62
+ end
63
+
64
+ base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
65
+ cache_path = (cache_path || File.join(Dir.home, ".infrawrench", "aws_pricing_data")).to_s
66
+
67
+ begin
68
+ latest = download("#{base_url}/latest", token, &:read_body).to_s.dup.force_encoding(Encoding::UTF_8).scrub.strip
69
+ unless latest.match?(/\A[A-Za-z0-9._-]+\z/)
70
+ raise AWSPricingDataError, %(latest holds "#{latest}", which is not a timestamp.)
71
+ end
72
+
73
+ name = "bundle-#{latest}.json"
74
+ rescue StandardError => e
75
+ # 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)
77
+ raise if cached.nil?
78
+
79
+ name = cached
80
+ end
81
+
82
+ # The cache holds the decompressed JSON: xz decompression is most of a load, reading the file is not.
83
+ json = read_cached(File.join(cache_path, name)) if cache_pricing_data
84
+ if json.nil?
85
+ require_relative "bulk_instances/xz"
86
+ json = XZ.decompress do |xz|
87
+ download("#{base_url}/#{name}.xz", token) { |res| res.read_body { |chunk| xz << chunk } }
88
+ end
89
+ if cache_pricing_data
90
+ FileUtils.mkdir_p(cache_path)
91
+ # Write then rename, so a crash mid-write never leaves a truncated bundle to be read next time.
92
+ tmp = File.join(cache_path, "#{name}.#{Process.pid}.tmp")
93
+ File.binwrite(tmp, json)
94
+ File.rename(tmp, File.join(cache_path, name))
95
+ # Older bundles, including compressed ones cached by earlier versions of this SDK.
96
+ 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/)
98
+ end
99
+ end
100
+ end
101
+
102
+ AWSPricingData.new(JSON.parse(json, freeze: true))
103
+ end
104
+
105
+ private
106
+
107
+ # GETs +url+ and yields the successful response, retrying the 503 the bundles Worker
108
+ # answers when it cannot reach the Infrawrench API.
109
+ def download(url, token)
110
+ uri = URI(url)
111
+ (1..).each do |attempt|
112
+ retry_after = nil
113
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
114
+ request = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{token}", "User-Agent" => USER_AGENT)
115
+ http.request(request) do |res|
116
+ return yield res if res.is_a?(Net::HTTPSuccess)
117
+
118
+ status = res.code.to_i
119
+ if status == 401
120
+ raise AWSPricingDataError.new(
121
+ "The Infrawrench API did not accept the token (unknown, revoked or expired).", status:,
122
+ )
123
+ end
124
+ if status != 503 || attempt == 3
125
+ raise AWSPricingDataError.new("GET #{url} failed with status #{status}.", status:)
126
+ end
127
+
128
+ retry_after = Float(res["Retry-After"], exception: false)
129
+ retry_after = 5 if retry_after.nil? || retry_after.nan? || retry_after.zero?
130
+ end
131
+ end
132
+ sleep([retry_after, 0].max)
133
+ end
134
+ rescue *TRANSPORT_ERRORS => e
135
+ raise AWSPricingDataError, "GET #{url} failed: #{e.message}"
136
+ end
137
+
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
141
+ rescue SystemCallError
142
+ nil
143
+ end
144
+
145
+ def read_cached(path)
146
+ File.read(path, encoding: Encoding::UTF_8)
147
+ rescue SystemCallError
148
+ nil
149
+ end
150
+ end
151
+ end
152
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: infrawrench-bulk_instances
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Infrawrench LLC
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: fiddle
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.1'
26
+ email:
27
+ - astrid@infrawrench.com
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ files:
32
+ - LICENSE
33
+ - README.md
34
+ - lib/infrawrench/bulk_instances.rb
35
+ - lib/infrawrench/bulk_instances/aws_instance.rb
36
+ - lib/infrawrench/bulk_instances/aws_pricing_data.rb
37
+ - lib/infrawrench/bulk_instances/error.rb
38
+ - lib/infrawrench/bulk_instances/types.rb
39
+ - lib/infrawrench/bulk_instances/version.rb
40
+ - lib/infrawrench/bulk_instances/xz.rb
41
+ homepage: https://infrawrench.com
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ homepage_uri: https://infrawrench.com
46
+ source_code_uri: https://github.com/Infrawrench/bulk-instances-sdks/tree/main/ruby
47
+ rubygems_mfa_required: 'true'
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.2'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.6.9
63
+ specification_version: 4
64
+ summary: AWS EC2 instance attributes and pricing, with full history, from Infrawrench.
65
+ test_files: []