calvery 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/LICENSE +21 -0
- data/README.md +82 -0
- data/lib/calvery.rb +196 -0
- metadata +50 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 60248493396e75a137456f80208ff08591e633fa186c9a5181ae1c6e637ba708
|
|
4
|
+
data.tar.gz: da185eba5dafb7927e0c8c0f1a3e9f2334e07befbbf6652dc64af479c144e78c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8c5a294c0996b79fbc4f30201cdc5d7fd2bb547aeac7c42caeafff1dbc977c127bcef89f62c0e4c405f43b558e30a4cf48a85c37938b1bca87a979ba36dfb039
|
|
7
|
+
data.tar.gz: 7b31062ec3b9d5657b548585b1ac8c0737eaeaaceee22b8c99859ab3f740f8f752e8e51812f7012b903be04edcb86e80b4fded15844385362319b3d545e66cf2
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Renzy Armstrong / Calvery
|
|
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,82 @@
|
|
|
1
|
+
# Calvery SDK — Ruby
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK for [Calvery Vault](https://calvery.xyz) secret manager. Ruby 3.0+. Zero runtime deps — stdlib only.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
gem install calvery
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or in `Gemfile`:
|
|
12
|
+
```ruby
|
|
13
|
+
gem "calvery", "~> 0.1"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quickstart
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
require "calvery"
|
|
20
|
+
|
|
21
|
+
client = Calvery::Client.new(ENV["CVSM_TOKEN"], "acme-corp")
|
|
22
|
+
|
|
23
|
+
# Single secret
|
|
24
|
+
db_url = client.get("DATABASE_URL")
|
|
25
|
+
|
|
26
|
+
# All secrets
|
|
27
|
+
all = client.get_all
|
|
28
|
+
puts all.keys
|
|
29
|
+
|
|
30
|
+
# Inject into ENV (skip ones already set)
|
|
31
|
+
injected = client.inject!(overwrite: false)
|
|
32
|
+
puts "Injected #{injected.size} vars"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Config
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
client = Calvery::Client.new(
|
|
39
|
+
ENV["CVSM_TOKEN"],
|
|
40
|
+
"acme-corp",
|
|
41
|
+
base_url: "https://vault.your-company.internal",
|
|
42
|
+
environment: "staging",
|
|
43
|
+
cache_ttl: 60,
|
|
44
|
+
max_retries: 5,
|
|
45
|
+
timeout: 30,
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Error handling
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
begin
|
|
53
|
+
val = client.get("DATABASE_URL")
|
|
54
|
+
rescue Calvery::NotFound => e
|
|
55
|
+
warn "secret missing: #{e.message}"
|
|
56
|
+
rescue Calvery::AuthError => e
|
|
57
|
+
warn "token invalid: #{e.message}"
|
|
58
|
+
rescue Calvery::NetworkError => e
|
|
59
|
+
warn "network: #{e.message}"
|
|
60
|
+
rescue Calvery::ServerError => e
|
|
61
|
+
warn "HTTP #{e.status}: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Rails
|
|
66
|
+
|
|
67
|
+
`config/initializers/calvery.rb`:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
Rails.application.config.calvery = Calvery::Client.new(
|
|
71
|
+
ENV.fetch("CVSM_TOKEN"),
|
|
72
|
+
ENV.fetch("CVSM_TEAM"),
|
|
73
|
+
environment: Rails.env,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Load ke ENV saat boot
|
|
77
|
+
Rails.application.config.calvery.inject!(overwrite: false)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT — support support@calvery.xyz, issues [github.com/RenzyArmstrong/Calvery-Vault](https://github.com/RenzyArmstrong/Calvery-Vault/issues)
|
data/lib/calvery.rb
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
# Calvery Vault Ruby SDK.
|
|
8
|
+
#
|
|
9
|
+
# client = Calvery::Client.new(ENV["CVSM_TOKEN"], "acme-corp")
|
|
10
|
+
# client.get("DATABASE_URL")
|
|
11
|
+
# client.get_all
|
|
12
|
+
# client.inject!(overwrite: false) # populate ENV
|
|
13
|
+
module Calvery
|
|
14
|
+
VERSION = "0.1.0"
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.calvery.xyz"
|
|
17
|
+
DEFAULT_ENVIRONMENT = "production"
|
|
18
|
+
DEFAULT_CACHE_TTL = 30 # seconds
|
|
19
|
+
DEFAULT_MAX_RETRIES = 3
|
|
20
|
+
DEFAULT_TIMEOUT = 10 # seconds
|
|
21
|
+
|
|
22
|
+
UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
23
|
+
|
|
24
|
+
class Error < StandardError; end
|
|
25
|
+
class ConfigError < Error; end
|
|
26
|
+
class AuthError < Error; end
|
|
27
|
+
class NotFound < Error; end
|
|
28
|
+
class NetworkError < Error; end
|
|
29
|
+
class DecodeError < Error; end
|
|
30
|
+
class ServerError < Error
|
|
31
|
+
attr_reader :status
|
|
32
|
+
def initialize(msg, status)
|
|
33
|
+
super(msg)
|
|
34
|
+
@status = status
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Client
|
|
39
|
+
# @param token [String] Personal access token (cvsm_...)
|
|
40
|
+
# @param team [String] Team slug or UUID
|
|
41
|
+
# @param options [Hash] :base_url, :environment, :cache_ttl, :max_retries, :timeout
|
|
42
|
+
def initialize(token, team, **options)
|
|
43
|
+
raise ConfigError, "token wajib" if token.nil? || token.empty?
|
|
44
|
+
raise ConfigError, "team wajib (slug atau UUID)" if team.nil? || team.empty?
|
|
45
|
+
|
|
46
|
+
@token = token
|
|
47
|
+
@team_input = team
|
|
48
|
+
@base_url = (options[:base_url] || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
49
|
+
@default_env = options[:environment] || DEFAULT_ENVIRONMENT
|
|
50
|
+
@cache_ttl = options[:cache_ttl] || DEFAULT_CACHE_TTL
|
|
51
|
+
@max_retries = options[:max_retries] || DEFAULT_MAX_RETRIES
|
|
52
|
+
@timeout = options[:timeout] || DEFAULT_TIMEOUT
|
|
53
|
+
|
|
54
|
+
@resolved_team_id = nil
|
|
55
|
+
@cache = {}
|
|
56
|
+
@mutex = Mutex.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Ambil satu secret by name (default env).
|
|
60
|
+
def get(name, environment: nil)
|
|
61
|
+
env = environment || @default_env
|
|
62
|
+
all = get_all(environment: env)
|
|
63
|
+
raise NotFound, %(secret "#{name}" tidak ditemukan di environment "#{env}") unless all.key?(name)
|
|
64
|
+
all[name]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Ambil semua secret untuk environment tertentu sebagai Hash.
|
|
68
|
+
def get_all(environment: nil)
|
|
69
|
+
env = environment || @default_env
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
entry = @cache[env]
|
|
72
|
+
return entry[:data].dup if entry && entry[:expires_at] > Time.now.to_i
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
team_id = resolve_team_id
|
|
76
|
+
query = URI.encode_www_form(format: "json", environment: env)
|
|
77
|
+
url = "#{@base_url}/api/v1/teams/#{team_id}/secrets/export?#{query}"
|
|
78
|
+
body = do_with_retry(:get, url)
|
|
79
|
+
data = parse_json(body)
|
|
80
|
+
raise DecodeError, "response bukan object JSON" unless data.is_a?(Hash)
|
|
81
|
+
|
|
82
|
+
if @cache_ttl.positive?
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
@cache[env] = { data: data.dup, expires_at: Time.now.to_i + @cache_ttl }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
data
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Populate ENV dari semua secret. Return array nama yang di-inject.
|
|
91
|
+
def inject!(overwrite: false, environment: nil)
|
|
92
|
+
secrets = get_all(environment: environment)
|
|
93
|
+
injected = []
|
|
94
|
+
secrets.each do |k, v|
|
|
95
|
+
if !overwrite && !ENV[k].to_s.empty?
|
|
96
|
+
next
|
|
97
|
+
end
|
|
98
|
+
ENV[k] = v
|
|
99
|
+
injected << k
|
|
100
|
+
end
|
|
101
|
+
injected
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def clear_cache
|
|
105
|
+
@mutex.synchronize { @cache.clear }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def resolve_team_id
|
|
111
|
+
@mutex.synchronize { return @resolved_team_id if @resolved_team_id }
|
|
112
|
+
|
|
113
|
+
if @team_input.match?(UUID_RE)
|
|
114
|
+
@mutex.synchronize { @resolved_team_id = @team_input }
|
|
115
|
+
return @team_input
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
body = do_with_retry(:get, "#{@base_url}/api/v1/teams")
|
|
119
|
+
data = parse_json(body)
|
|
120
|
+
teams = data.is_a?(Hash) ? data["teams"] : nil
|
|
121
|
+
raise DecodeError, "gagal decode list teams" unless teams.is_a?(Array)
|
|
122
|
+
|
|
123
|
+
teams.each do |t|
|
|
124
|
+
if t.is_a?(Hash) && t["slug"] == @team_input
|
|
125
|
+
@mutex.synchronize { @resolved_team_id = t["id"] }
|
|
126
|
+
return t["id"]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
raise NotFound, %(team dengan slug "#{@team_input}" tidak ditemukan di akun ini)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def do_with_retry(method, url_str)
|
|
133
|
+
uri = URI(url_str)
|
|
134
|
+
last = nil
|
|
135
|
+
0.upto(@max_retries) do |attempt|
|
|
136
|
+
begin
|
|
137
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
138
|
+
open_timeout: @timeout, read_timeout: @timeout) do |http|
|
|
139
|
+
req = case method
|
|
140
|
+
when :get then Net::HTTP::Get.new(uri.request_uri)
|
|
141
|
+
else raise ArgumentError, "method #{method} tidak didukung"
|
|
142
|
+
end
|
|
143
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
144
|
+
req["User-Agent"] = "calvery-ruby/#{VERSION}"
|
|
145
|
+
req["Accept"] = "application/json"
|
|
146
|
+
|
|
147
|
+
res = http.request(req)
|
|
148
|
+
status = res.code.to_i
|
|
149
|
+
case status
|
|
150
|
+
when 401, 403
|
|
151
|
+
raise AuthError, read_error_msg(res.body, status)
|
|
152
|
+
when 500..599
|
|
153
|
+
if attempt < @max_retries
|
|
154
|
+
sleep(backoff(attempt))
|
|
155
|
+
next
|
|
156
|
+
end
|
|
157
|
+
raise ServerError.new("HTTP #{status}: #{read_error_msg(res.body, status)}", status)
|
|
158
|
+
when 400..499
|
|
159
|
+
raise ServerError.new("HTTP #{status}: #{read_error_msg(res.body, status)}", status)
|
|
160
|
+
else
|
|
161
|
+
return res.body.to_s
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
rescue Timeout::Error, Errno::ECONNREFUSED, SocketError, IOError => e
|
|
165
|
+
last = e
|
|
166
|
+
if attempt < @max_retries
|
|
167
|
+
sleep(backoff(attempt))
|
|
168
|
+
next
|
|
169
|
+
end
|
|
170
|
+
raise NetworkError, "gagal konek ke #{@base_url}: #{e.message}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
raise NetworkError, "unreachable: #{last&.message}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def backoff(attempt)
|
|
177
|
+
base_ms = [100 * (2**attempt), 2000].min
|
|
178
|
+
(base_ms + rand(100)) / 1000.0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def read_error_msg(body, status)
|
|
182
|
+
data = parse_json(body)
|
|
183
|
+
return data["error"] if data.is_a?(Hash) && data["error"].is_a?(String)
|
|
184
|
+
"HTTP #{status}"
|
|
185
|
+
rescue
|
|
186
|
+
"HTTP #{status}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def parse_json(body)
|
|
190
|
+
return nil if body.nil? || body.empty?
|
|
191
|
+
JSON.parse(body)
|
|
192
|
+
rescue JSON::ParserError
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: calvery
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Calvery
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-22 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Get, list, and inject secrets from Calvery Vault into your Ruby app (Rails,
|
|
14
|
+
Sidekiq, Rake).
|
|
15
|
+
email:
|
|
16
|
+
- support@calvery.xyz
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- lib/calvery.rb
|
|
24
|
+
homepage: https://calvery.xyz
|
|
25
|
+
licenses:
|
|
26
|
+
- MIT
|
|
27
|
+
metadata:
|
|
28
|
+
source_code_uri: https://github.com/RenzyArmstrong/calvery-sdks
|
|
29
|
+
bug_tracker_uri: https://github.com/RenzyArmstrong/Calvery-Vault/issues
|
|
30
|
+
documentation_uri: https://docs.calvery.xyz/sdk/ruby/
|
|
31
|
+
post_install_message:
|
|
32
|
+
rdoc_options: []
|
|
33
|
+
require_paths:
|
|
34
|
+
- lib
|
|
35
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.0'
|
|
40
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: '0'
|
|
45
|
+
requirements: []
|
|
46
|
+
rubygems_version: 3.3.5
|
|
47
|
+
signing_key:
|
|
48
|
+
specification_version: 4
|
|
49
|
+
summary: Official Ruby SDK for Calvery Vault secret manager
|
|
50
|
+
test_files: []
|