File: ec2_metadata.rb

package info (click to toggle)
ruby-aws-sdk-core 3.212.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 2,232 kB
  • sloc: ruby: 17,533; makefile: 4
file content (238 lines) | stat: -rw-r--r-- 8,163 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# frozen_string_literal: true

require 'time'
require 'net/http'

module Aws
  # A client that can query version 2 of the EC2 Instance Metadata
  class EC2Metadata
    # Path for PUT request for token
    # @api private
    METADATA_TOKEN_PATH = '/latest/api/token'.freeze

    # Raised when the PUT request is not valid. This would be thrown if
    # `token_ttl` is not an Integer.
    # @api private
    class TokenRetrievalError < RuntimeError; end

    # Token has expired, and the request can be retried with a new token.
    # @api private
    class TokenExpiredError < RuntimeError; end

    # The requested metadata path does not exist.
    # @api private
    class MetadataNotFoundError < RuntimeError; end

    # The request is not allowed or IMDS is turned off.
    # @api private
    class RequestForbiddenError < RuntimeError; end

    # Creates a client that can query version 2 of the EC2 Instance Metadata
    #   service (IMDS).
    #
    # @note Customers using containers may need to increase their hop limit
    #   to access IMDSv2.
    # @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html#instance-metadata-transition-to-version-2
    #
    # @param [Hash] options
    # @option options [Integer] :token_ttl (21600) The session token's TTL,
    #   defaulting to 6 hours.
    # @option options [Integer] :retries (3) The number of retries for failed
    #   requests.
    # @option options [String] :endpoint ('http://169.254.169.254') The IMDS
    #   endpoint. This option has precedence over the :endpoint_mode.
    # @option options [String] :endpoint_mode ('IPv4') The endpoint mode for
    #   the instance metadata service. This is either 'IPv4'
    #   ('http://169.254.169.254') or 'IPv6' ('http://[fd00:ec2::254]').
    # @option options [Integer] :port (80) The IMDS endpoint port.
    # @option options [Integer] :http_open_timeout (1) The number of seconds to
    #   wait for the connection to open.
    # @option options [Integer] :http_read_timeout (1) The number of seconds for
    #   one chunk of data to be read.
    # @option options [IO] :http_debug_output An output stream for debugging. Do
    #   not use this in production.
    # @option options [Integer,Proc] :backoff A backoff used for retryable
    #   requests. When given an Integer, it sleeps that amount. When given a
    #   Proc, it is called with the current number of failed retries.
    def initialize(options = {})
      @token_ttl = options[:token_ttl] || 21_600
      @retries = options[:retries] || 3
      @backoff = backoff(options[:backoff])

      endpoint_mode = options[:endpoint_mode] || 'IPv4'
      @endpoint = resolve_endpoint(options[:endpoint], endpoint_mode)
      @port = options[:port] || 80

      @http_open_timeout = options[:http_open_timeout] || 1
      @http_read_timeout = options[:http_read_timeout] || 1
      @http_debug_output = options[:http_debug_output]

      @token = nil
      @mutex = Mutex.new
    end

    # Fetches a given metadata category using a String path, and returns the
    #   result as a String. A path starts with the API version (usually
    #   "/latest/"). See the instance data categories for possible paths.
    #
    # @example Fetching the instance ID
    #
    #   ec2_metadata = Aws::EC2Metadata.new
    #   ec2_metadata.get('/latest/meta-data/instance-id')
    #   => "i-023a25f10a73a0f79"
    #
    # @note This implementation always returns a String and will not parse any
    #   responses. Parsable responses may include JSON objects or directory
    #   listings, which are strings separated by line feeds (ASCII 10).
    #
    # @example Fetching and parsing JSON meta-data
    #
    #   require 'json'
    #   data = ec2_metadata.get('/latest/dynamic/instance-identity/document')
    #   JSON.parse(data)
    #   => {"accountId"=>"012345678912", ... }
    #
    # @example Fetching and parsing directory listings
    #
    #   listing = ec2_metadata.get('/latest/meta-data')
    #   listing.split(10.chr)
    #   => ["ami-id", "ami-launch-index", ...]
    #
    # @note Unlike other services, IMDS does not have a service API model. This
    #   means that we cannot confidently generate code with methods and
    #   response structures. This implementation ensures that new IMDS features
    #   are always supported by being deployed to the instance and does not
    #   require code changes.
    #
    # @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-categories.html
    # @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html
    # @param [String] path The full path to the metadata.
    def get(path)
      retry_errors(max_retries: @retries) do
        @mutex.synchronize do
          fetch_token unless @token && !@token.expired?
        end

        open_connection do |conn|
          http_get(conn, path, @token.value)
        end
      end
    end

    private

    def resolve_endpoint(endpoint, endpoint_mode)
      return endpoint if endpoint

      case endpoint_mode.downcase
      when 'ipv4' then 'http://169.254.169.254'
      when 'ipv6' then 'http://[fd00:ec2::254]'
      else
        raise ArgumentError,
              ':endpoint_mode is not valid, expected IPv4 or IPv6, '\
              "got: #{endpoint_mode}"
      end
    end

    def fetch_token
      open_connection do |conn|
        created_time = Time.now
        token_value, token_ttl = http_put(conn, @token_ttl)
        @token = Token.new(value: token_value, ttl: token_ttl, created_time: created_time)
      end
    end

    def http_get(connection, path, token)
      headers = {
        'User-Agent' => "aws-sdk-ruby3/#{CORE_GEM_VERSION}",
        'x-aws-ec2-metadata-token' => token
      }
      request = Net::HTTP::Get.new(path, headers)
      response = connection.request(request)

      case response.code.to_i
      when 200
        response.body
      when 401
        raise TokenExpiredError
      when 404
        raise MetadataNotFoundError
      end
    end

    def http_put(connection, ttl)
      headers = {
        'User-Agent' => "aws-sdk-ruby3/#{CORE_GEM_VERSION}",
        'x-aws-ec2-metadata-token-ttl-seconds' => ttl.to_s
      }
      request = Net::HTTP::Put.new(METADATA_TOKEN_PATH, headers)
      response = connection.request(request)

      case response.code.to_i
      when 200
        [
          response.body,
          response.header['x-aws-ec2-metadata-token-ttl-seconds'].to_i
        ]
      when 400
        raise TokenRetrievalError
      when 403
        raise RequestForbiddenError
      end
    end

    def open_connection
      uri = URI.parse(@endpoint)
      http = Net::HTTP.new(uri.hostname || @endpoint, uri.port || @port)
      http.open_timeout = @http_open_timeout
      http.read_timeout = @http_read_timeout
      http.set_debug_output(@http_debug_output) if @http_debug_output
      http.start
      yield(http).tap { http.finish }
    end

    def retry_errors(options = {}, &_block)
      max_retries = options[:max_retries]
      retries = 0
      begin
        yield
      # These errors should not be retried.
      rescue TokenRetrievalError, MetadataNotFoundError, RequestForbiddenError
        raise
      # StandardError is not ideal but it covers Net::HTTP errors.
      # https://gist.github.com/tenderlove/245188
      rescue StandardError, TokenExpiredError
        raise unless retries < max_retries

        @backoff.call(retries)
        retries += 1
        retry
      end
    end

    def backoff(backoff)
      case backoff
      when Proc then backoff
      when Numeric then ->(_) { Kernel.sleep(backoff) }
      else ->(num_failures) { Kernel.sleep(1.2**num_failures) }
      end
    end

    # @api private
    class Token
      def initialize(options = {})
        @ttl   = options[:ttl]
        @value = options[:value]
        @created_time = options[:created_time] || Time.now
      end

      # [String] Returns the token value.
      attr_reader :value

      # [Boolean] Returns true if the token expired.
      def expired?
        Time.now - @created_time > @ttl
      end
    end
  end
end