envlet 0.1.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/README.md +28 -0
- data/lib/envlet/version.rb +3 -0
- data/lib/envlet.rb +202 -0
- metadata +49 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7d95d6b63c04a40b1c02fbd3982eeda60431a707577a7d76c736afc64ef4135d
|
|
4
|
+
data.tar.gz: 1094f02bf8515fd5887c3f4d8c767a7c80afc77bf338b1622098a228dcf1d615
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: aeed2b588d60817631047dac3c4299018fcca8eca07b4970daa6c8b68aa835b1202c3603193cd7587083774db9b2b3a272a2e6b292b5a200a39cd7a5dde37084
|
|
7
|
+
data.tar.gz: 7f3415e50ec21f23430a6ade7809af34df29b7d09bd4531dce38d32b4bba5ca829f4b1d2b7c2e08284a5dfd18b273174f46c2204755f72fd30286bd500493be6
|
data/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# envlet
|
|
2
|
+
|
|
3
|
+
Pull your Envlet environment at runtime. The host platform holds one secret,
|
|
4
|
+
an Envlet token, and your app loads everything else at boot.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
require "envlet"
|
|
8
|
+
|
|
9
|
+
Envlet.inject
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`Envlet.inject` fetches every value the token's identity may read and sets
|
|
13
|
+
`ENV`. Values already set by the host are never overwritten. Pass
|
|
14
|
+
`override: true` to force Envlet's values.
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
values = Envlet.load
|
|
18
|
+
stripe_key = Envlet.get("STRIPE_KEY")
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`Envlet.load` returns a hash of names and values. `Envlet.get(name)` fetches
|
|
22
|
+
one value and raises `Envlet::NotFoundError` when the identity cannot read it.
|
|
23
|
+
|
|
24
|
+
Configuration comes from `ENVLET_TOKEN` or the `token:` option. Set
|
|
25
|
+
`ENVLET_API_URL` or pass `api_url:` to use another API endpoint. Requests retry
|
|
26
|
+
briefly on network errors and 5xx responses unless `retry: false` is set.
|
|
27
|
+
Authentication failures raise `Envlet::AuthError`; every Envlet error exposes
|
|
28
|
+
`code` and `status`. The SDK never returns stale or partial values.
|
data/lib/envlet.rb
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
require_relative "envlet/version"
|
|
8
|
+
|
|
9
|
+
module Envlet
|
|
10
|
+
DEFAULT_API_URL = "https://api.envlet.dev"
|
|
11
|
+
RETRY_ATTEMPTS = 3
|
|
12
|
+
RETRY_DELAYS = [0.4, 1.6].freeze
|
|
13
|
+
OPEN_TIMEOUT = 2
|
|
14
|
+
READ_TIMEOUT = 2
|
|
15
|
+
|
|
16
|
+
class Error < StandardError
|
|
17
|
+
attr_reader :code, :status
|
|
18
|
+
|
|
19
|
+
def initialize(code, message, status = nil)
|
|
20
|
+
super(message)
|
|
21
|
+
@code = code
|
|
22
|
+
@status = status
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class AuthError < Error
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class NotFoundError < Error
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
def load(**options)
|
|
34
|
+
validate_options(options, :token, :api_url, :retry)
|
|
35
|
+
body = request(
|
|
36
|
+
"/v1/values",
|
|
37
|
+
token: options[:token],
|
|
38
|
+
api_url: options[:api_url],
|
|
39
|
+
retry_enabled: options.fetch(:retry, true)
|
|
40
|
+
)
|
|
41
|
+
values = body["values"] if body.is_a?(Hash)
|
|
42
|
+
unless values.is_a?(Hash) && values.all? { |name, value| name.is_a?(String) && value.is_a?(String) }
|
|
43
|
+
raise Error.new("invalid_response", "the values response was not in the expected shape")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
values
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def get(name, **options)
|
|
50
|
+
validate_options(options, :token, :api_url, :retry)
|
|
51
|
+
encoded_name = URI.encode_www_form_component(name.to_s).gsub("+", "%20")
|
|
52
|
+
body = request(
|
|
53
|
+
"/v1/values/#{encoded_name}",
|
|
54
|
+
token: options[:token],
|
|
55
|
+
api_url: options[:api_url],
|
|
56
|
+
retry_enabled: options.fetch(:retry, true)
|
|
57
|
+
)
|
|
58
|
+
value = body["value"] if body.is_a?(Hash)
|
|
59
|
+
unless value.is_a?(String)
|
|
60
|
+
raise Error.new("invalid_response", "the value response was not in the expected shape")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def inject(**options)
|
|
67
|
+
validate_options(options, :token, :api_url, :retry, :override)
|
|
68
|
+
values = load(
|
|
69
|
+
token: options[:token],
|
|
70
|
+
api_url: options[:api_url],
|
|
71
|
+
retry: options.fetch(:retry, true)
|
|
72
|
+
)
|
|
73
|
+
override = options.fetch(:override, false)
|
|
74
|
+
values.each do |name, value|
|
|
75
|
+
next if !override && ENV.key?(name)
|
|
76
|
+
|
|
77
|
+
ENV[name] = value
|
|
78
|
+
end
|
|
79
|
+
values
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def validate_options(options, *allowed)
|
|
85
|
+
unknown = options.keys - allowed
|
|
86
|
+
return if unknown.empty?
|
|
87
|
+
|
|
88
|
+
raise ArgumentError, "unknown keyword: #{unknown.first}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def request(path, token:, api_url:, retry_enabled:)
|
|
92
|
+
resolved_token = resolve_token(token)
|
|
93
|
+
base_url = resolve_api_url(api_url)
|
|
94
|
+
uri = build_uri(base_url, path)
|
|
95
|
+
attempts = retry_enabled == false ? 1 : RETRY_ATTEMPTS
|
|
96
|
+
last_error = nil
|
|
97
|
+
|
|
98
|
+
attempts.times do |attempt|
|
|
99
|
+
sleep(RETRY_DELAYS.fetch(attempt - 1, RETRY_DELAYS.last)) if attempt > 0
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
response = perform_http_request(uri, resolved_token)
|
|
103
|
+
rescue Timeout::Error, EOFError, IOError, SocketError, SystemCallError,
|
|
104
|
+
OpenSSL::SSL::SSLError, Net::ProtocolError => error
|
|
105
|
+
last_error = Error.new(
|
|
106
|
+
"network_error",
|
|
107
|
+
"could not reach #{base_url}: #{error.message}"
|
|
108
|
+
)
|
|
109
|
+
next
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
return parse_success_response(response) if response.is_a?(Net::HTTPOK)
|
|
113
|
+
|
|
114
|
+
code, message = parse_error_response(response)
|
|
115
|
+
status = response.code.to_i
|
|
116
|
+
|
|
117
|
+
raise AuthError.new(code, message, status) if status == 401 || status == 403
|
|
118
|
+
raise NotFoundError.new(code, message, status) if status == 404
|
|
119
|
+
|
|
120
|
+
if status >= 500
|
|
121
|
+
last_error = Error.new(code, message, status)
|
|
122
|
+
next
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
raise Error.new(code, message, status)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
raise(last_error || Error.new("request_failed", "request failed"))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def resolve_token(token)
|
|
132
|
+
token = ENV["ENVLET_TOKEN"] if token.nil?
|
|
133
|
+
unless token.nil? || token.is_a?(String)
|
|
134
|
+
raise Error.new("config_invalid", "the token must be a string")
|
|
135
|
+
end
|
|
136
|
+
if token.nil? || token.empty?
|
|
137
|
+
raise Error.new(
|
|
138
|
+
"config_missing",
|
|
139
|
+
"no token: pass token: or set ENVLET_TOKEN"
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
token
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def resolve_api_url(api_url)
|
|
147
|
+
api_url = ENV["ENVLET_API_URL"] if api_url.nil?
|
|
148
|
+
api_url.nil? ? DEFAULT_API_URL : api_url
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def build_uri(api_url, path)
|
|
152
|
+
unless api_url.is_a?(String) && !api_url.empty?
|
|
153
|
+
raise Error.new("config_invalid", "the API URL must be a non-empty string")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
uri = URI.parse("#{api_url.sub(%r{/$}, "")}#{path}")
|
|
157
|
+
unless uri.is_a?(URI::HTTP) && uri.host
|
|
158
|
+
raise Error.new("config_invalid", "the API URL must use HTTP or HTTPS")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
uri
|
|
162
|
+
rescue URI::InvalidURIError => error
|
|
163
|
+
raise Error.new("config_invalid", "the API URL is invalid: #{error.message}")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def perform_http_request(uri, token)
|
|
167
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
168
|
+
http.use_ssl = uri.scheme == "https"
|
|
169
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
170
|
+
http.read_timeout = READ_TIMEOUT
|
|
171
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
172
|
+
request["Authorization"] = "Bearer #{token}"
|
|
173
|
+
http.start { |connection| connection.request(request) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def parse_success_response(response)
|
|
177
|
+
JSON.parse(response.body)
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
raise Error.new("invalid_response", "the response was not valid JSON", response.code.to_i)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def parse_error_response(response)
|
|
183
|
+
code = "request_failed"
|
|
184
|
+
message = "request failed with status #{response.code}"
|
|
185
|
+
|
|
186
|
+
begin
|
|
187
|
+
body = JSON.parse(response.body)
|
|
188
|
+
envelope = body["error"] if body.is_a?(Hash)
|
|
189
|
+
if envelope.is_a?(Hash)
|
|
190
|
+
code = envelope["code"] if envelope["code"].is_a?(String)
|
|
191
|
+
message = envelope["message"] if envelope["message"].is_a?(String)
|
|
192
|
+
end
|
|
193
|
+
rescue JSON::ParserError
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
[code, message]
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private_constant :DEFAULT_API_URL, :RETRY_ATTEMPTS, :RETRY_DELAYS,
|
|
201
|
+
:OPEN_TIMEOUT, :READ_TIMEOUT
|
|
202
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: envlet
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- SELCOR
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-02 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Load, get, or inject Envlet environment values with one token.
|
|
14
|
+
email:
|
|
15
|
+
- admin@selcor.ai
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- README.md
|
|
21
|
+
- lib/envlet.rb
|
|
22
|
+
- lib/envlet/version.rb
|
|
23
|
+
homepage: https://envlet.dev
|
|
24
|
+
licenses:
|
|
25
|
+
- MIT
|
|
26
|
+
metadata:
|
|
27
|
+
documentation_uri: https://docs.envlet.dev
|
|
28
|
+
homepage_uri: https://envlet.dev
|
|
29
|
+
rubygems_mfa_required: 'true'
|
|
30
|
+
post_install_message:
|
|
31
|
+
rdoc_options: []
|
|
32
|
+
require_paths:
|
|
33
|
+
- lib
|
|
34
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '2.6'
|
|
39
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '0'
|
|
44
|
+
requirements: []
|
|
45
|
+
rubygems_version: 3.5.22
|
|
46
|
+
signing_key:
|
|
47
|
+
specification_version: 4
|
|
48
|
+
summary: Load Envlet environment values at runtime
|
|
49
|
+
test_files: []
|