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 +7 -0
- data/LICENSE +21 -0
- data/README.md +63 -0
- data/lib/infrawrench/bulk_instances/aws_instance.rb +145 -0
- data/lib/infrawrench/bulk_instances/aws_pricing_data.rb +107 -0
- data/lib/infrawrench/bulk_instances/error.rb +16 -0
- data/lib/infrawrench/bulk_instances/types.rb +83 -0
- data/lib/infrawrench/bulk_instances/version.rb +7 -0
- data/lib/infrawrench/bulk_instances/xz.rb +125 -0
- data/lib/infrawrench/bulk_instances.rb +152 -0
- metadata +65 -0
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,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: []
|