legionio 1.6.1 → 1.6.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97707f0bdbc4ad1ce6b714bcb703b6d53725210b6565d700e8677db9912a9ab8
4
- data.tar.gz: 3e2171d5dd1de801ed929a0581e4d3920845be921a528147c4d6e9ddd122e2de
3
+ metadata.gz: 49e219b9a9c790b6ec63903daee3a26194a5a1611cccd0013a05bb7a34a3d7c9
4
+ data.tar.gz: eedbb6e81c2ba386cee0718eb3aed307a021d25959579bb2a02c8bd011ca4fa5
5
5
  SHA512:
6
- metadata.gz: 1f9f67128de7fa3eac1cff947d2275fdc595f5bec7150c218af10cf08b4245abc455b48324492104c92da9f44e31633792d513eddcb1b6316313381eace45c00
7
- data.tar.gz: 7b5d3952a503ce07268076a20eecc86828ef5d7b53d87614c2bb874fbe6b55398e740f3bd8ab448f7047130917612bb2b3a18dcb2c116699c32663ab80862647
6
+ metadata.gz: 141f1ad15ae3595a411e4e1ddaf7fa4544bd1fd5aa23be7425574e783a55581773d0a860ace1d7f6dce97bf3004b6fc42998374cdfe8b3555b01547e98be5f1e
7
+ data.tar.gz: a05b2f2cb76d9237dc785b396af6f72b03657866ba4f57fd9b7673594d3212634e9c920d90529c5134544cbdb8d32e9994b188e155366f6f69f7df803355dc90
data/.gitignore CHANGED
@@ -15,7 +15,8 @@
15
15
  legionio.key
16
16
  # runtime artifacts
17
17
  .DS_Store
18
- legionio.db
18
+ *.db
19
+ *.json
19
20
  logs/
20
21
  # local settings (may contain secrets)
21
22
  settings/
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.6.2] - 2026-03-26
4
+
5
+ ### Fixed
6
+ - `legionio update` remote check failed for all gems due to TCP connection exhaustion (24 parallel SSL connections to rubygems.org)
7
+ - Replace thread pool with 4 batched threads using persistent HTTP keep-alive connections (55 gems in ~4s)
8
+
3
9
  ## [1.6.1] - 2026-03-26
4
10
 
5
11
  ### Fixed
@@ -127,36 +127,31 @@ module Legion
127
127
 
128
128
  def fetch_remote_versions_parallel(gem_names)
129
129
  results = Concurrent::Hash.new
130
- pool = Concurrent::FixedThreadPool.new([gem_names.size, 24].min)
131
- latch = Concurrent::CountDownLatch.new(gem_names.size)
132
-
133
- gem_names.each do |name|
134
- pool.post do
135
- version = fetch_remote_version(name)
136
- results[name] = version if version
137
- rescue StandardError => e
138
- Legion::Logging.debug("UpdateCommand#fetch_remote_version #{name}: #{e.message}") if defined?(Legion::Logging)
139
- ensure
140
- latch.count_down
130
+ thread_count = [gem_names.size, 4].min
131
+ slices = gem_names.each_slice((gem_names.size / thread_count.to_f).ceil).to_a
132
+ threads = slices.map do |batch|
133
+ Thread.new(batch) do |names|
134
+ fetch_batch(names, results)
141
135
  end
142
136
  end
143
-
144
- latch.wait(30)
145
- pool.shutdown
137
+ threads.each { |t| t.join(60) }
146
138
  results
147
139
  end
148
140
 
149
- def fetch_remote_version(name)
150
- uri = URI("https://rubygems.org/api/v1/versions/#{name}/latest.json")
151
- http = Net::HTTP.new(uri.host, uri.port)
152
- http.use_ssl = true
153
- http.open_timeout = 5
154
- http.read_timeout = 10
155
- response = http.request(Net::HTTP::Get.new(uri))
156
- return nil unless response.is_a?(Net::HTTPSuccess)
157
-
158
- data = ::JSON.parse(response.body)
159
- data['version']
141
+ def fetch_batch(names, results)
142
+ Net::HTTP.start('rubygems.org', 443, use_ssl: true, open_timeout: 10, read_timeout: 10) do |http|
143
+ names.each do |name|
144
+ response = http.request(Net::HTTP::Get.new("/api/v1/versions/#{name}/latest.json"))
145
+ next unless response.is_a?(Net::HTTPSuccess)
146
+
147
+ data = ::JSON.parse(response.body)
148
+ results[name] = data['version'] if data['version']
149
+ rescue StandardError => e
150
+ Legion::Logging.debug("UpdateCommand#fetch_batch #{name}: #{e.message}") if defined?(Legion::Logging)
151
+ end
152
+ end
153
+ rescue StandardError => e
154
+ Legion::Logging.debug("UpdateCommand#fetch_batch connection: #{e.message}") if defined?(Legion::Logging)
160
155
  end
161
156
 
162
157
  def display_results(out, results, before, after)
@@ -10,6 +10,30 @@ module Legion
10
10
  include Legion::Extensions::Helpers::Logger
11
11
  include Legion::JSON::Helper
12
12
 
13
+ module ClassMethods
14
+ def expose_as_mcp_tool(value = :_unset)
15
+ if value == :_unset
16
+ return @expose_as_mcp_tool unless @expose_as_mcp_tool.nil?
17
+
18
+ if defined?(Legion::Settings) && Legion::Settings.respond_to?(:dig)
19
+ Legion::Settings.dig(:mcp, :auto_expose_runners) || false
20
+ else
21
+ false
22
+ end
23
+ else
24
+ @expose_as_mcp_tool = value
25
+ end
26
+ end
27
+
28
+ def mcp_tool_prefix(value = :_unset)
29
+ if value == :_unset
30
+ @mcp_tool_prefix
31
+ else
32
+ @mcp_tool_prefix = value
33
+ end
34
+ end
35
+ end
36
+
13
37
  def function_example(function, example)
14
38
  function_set(function, :example, example)
15
39
  end
@@ -22,6 +46,34 @@ module Legion
22
46
  function_set(function, :desc, desc)
23
47
  end
24
48
 
49
+ def function_outputs(function, outputs)
50
+ function_set(function, :outputs, outputs)
51
+ end
52
+
53
+ def function_category(function, category)
54
+ function_set(function, :category, category)
55
+ end
56
+
57
+ def function_tags(function, tags)
58
+ function_set(function, :tags, tags)
59
+ end
60
+
61
+ def function_risk_tier(function, tier)
62
+ function_set(function, :risk_tier, tier)
63
+ end
64
+
65
+ def function_idempotent(function, value)
66
+ function_set(function, :idempotent, value)
67
+ end
68
+
69
+ def function_requires(function, deps)
70
+ function_set(function, :requires, deps)
71
+ end
72
+
73
+ def function_expose(function, value)
74
+ function_set(function, :expose, value)
75
+ end
76
+
25
77
  def function_set(function, key, value)
26
78
  unless respond_to? function
27
79
  log.debug "function_#{key} called but function doesn't exist, f: #{function}"
@@ -41,6 +93,7 @@ module Legion
41
93
  def self.included(base)
42
94
  base.send :extend, Legion::Extensions::Helpers::Core if base.instance_of?(Class)
43
95
  base.send :extend, Legion::Extensions::Helpers::Logger if base.instance_of?(Class)
96
+ base.extend ClassMethods if base.instance_of?(Class)
44
97
  base.extend base if base.instance_of?(Module)
45
98
  end
46
99
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.6.1'
4
+ VERSION = '1.6.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.1
4
+ version: 1.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -431,17 +431,9 @@ files:
431
431
  - deploy/helm/legion/values.yaml
432
432
  - docker_deploy.rb
433
433
  - docs/README.md
434
- - docs/plans/2026-03-18-config-import-vault-multicluster-design.md
435
- - docs/plans/2026-03-18-config-import-vault-multicluster-implementation.md
436
- - docs/plans/2026-03-18-core-lex-uplift-design.md
437
- - docs/plans/2026-03-18-core-lex-uplift-implementation.md
438
- - docs/plans/2026-03-18-legion-tty-default-cli-design.md
439
- - docs/plans/2026-03-18-legion-tty-default-cli-implementation.md
440
- - docs/plans/2026-03-19-hooks-expansion-design.md
441
434
  - exe/legion
442
435
  - exe/legionio
443
436
  - legionio.gemspec
444
- - legionio_local.db
445
437
  - lib/legion.rb
446
438
  - lib/legion/alerts.rb
447
439
  - lib/legion/api.rb
@@ -1,272 +0,0 @@
1
- # Config Import + Multi-Cluster Vault Design
2
-
3
- ## Problem
4
-
5
- LegionIO currently supports a single Vault cluster (`crypt.vault.address/port/token`). In enterprise environments, engineers work with multiple Vault clusters (dev, test, stage, production) and need different tokens for each. There's also no way to bootstrap a new developer's environment from a shared config — they must manually create JSON files in `~/.legionio/settings/`.
6
-
7
- ## Solution
8
-
9
- Three changes across three repos:
10
-
11
- ### 1. legion-crypt: Multi-Cluster Vault Support
12
-
13
- Upgrade `crypt.vault` from a single cluster to a named clusters hash with a `default` pointer.
14
-
15
- #### Settings Schema
16
-
17
- ```json
18
- {
19
- "crypt": {
20
- "vault": {
21
- "default": "prod",
22
- "clusters": {
23
- "dev": {
24
- "address": "vault-dev.example.com",
25
- "port": 8200,
26
- "protocol": "https",
27
- "namespace": "myapp",
28
- "token": null,
29
- "auth_method": "ldap"
30
- },
31
- "stage": {
32
- "address": "vault-stage.example.com",
33
- "port": 8200,
34
- "protocol": "https",
35
- "namespace": "myapp",
36
- "token": null,
37
- "auth_method": "ldap"
38
- },
39
- "prod": {
40
- "address": "vault.example.com",
41
- "port": 8200,
42
- "protocol": "https",
43
- "namespace": "myapp",
44
- "token": null,
45
- "auth_method": "ldap"
46
- }
47
- }
48
- }
49
- }
50
- }
51
- ```
52
-
53
- #### Backward Compatibility
54
-
55
- If `crypt.vault.clusters` is absent but `crypt.vault.address` is present, treat it as a single unnamed cluster (current behavior). The migration path is:
56
-
57
- ```ruby
58
- # Old style (still works)
59
- Legion::Settings[:crypt][:vault][:address] # => "vault.example.com"
60
-
61
- # New style
62
- Legion::Crypt.cluster(:prod) # => cluster config hash
63
- Legion::Crypt.cluster # => default cluster config hash
64
- Legion::Crypt.default_cluster # => "prod"
65
- ```
66
-
67
- #### New Module: `Legion::Crypt::VaultCluster`
68
-
69
- Manages per-cluster Vault connections:
70
-
71
- ```ruby
72
- module Legion::Crypt
73
- module VaultCluster
74
- # Get a configured ::Vault client for a named cluster
75
- def vault_client(name = nil)
76
- name ||= default_cluster_name
77
- @vault_clients ||= {}
78
- @vault_clients[name] ||= build_client(clusters[name])
79
- end
80
-
81
- # Cluster config hash
82
- def cluster(name = nil)
83
- name ||= default_cluster_name
84
- clusters[name]
85
- end
86
-
87
- def default_cluster_name
88
- vault_settings[:default] || clusters.keys.first
89
- end
90
-
91
- def clusters
92
- vault_settings[:clusters] || {}
93
- end
94
-
95
- # Connect to all clusters that have tokens
96
- def connect_all
97
- clusters.each do |name, config|
98
- next unless config[:token]
99
- connect_cluster(name)
100
- end
101
- end
102
-
103
- private
104
-
105
- def build_client(config)
106
- client = ::Vault::Client.new(
107
- address: "#{config[:protocol]}://#{config[:address]}:#{config[:port]}",
108
- token: config[:token]
109
- )
110
- client.namespace = config[:namespace] if config[:namespace]
111
- client
112
- end
113
- end
114
- end
115
- ```
116
-
117
- #### New Module: `Legion::Crypt::LdapAuth`
118
-
119
- LDAP authentication against Vault's LDAP auth method (HTTP API, no vault CLI):
120
-
121
- ```ruby
122
- module Legion::Crypt
123
- module LdapAuth
124
- # Authenticate to a single cluster via LDAP
125
- # POST /v1/auth/ldap/login/:username
126
- # Returns: { token:, lease_duration:, renewable:, policies: }
127
- def ldap_login(cluster_name:, username:, password:)
128
- client = vault_client(cluster_name)
129
- # Or raw HTTP if ::Vault gem doesn't expose ldap auth:
130
- response = client.post("/v1/auth/ldap/login/#{username}", password: password)
131
- token = response.auth.client_token
132
- # Store token in cluster config (in-memory only, not written to disk with password)
133
- clusters[cluster_name][:token] = token
134
- clusters[cluster_name][:connected] = true
135
- { token: token, lease_duration: response.auth.lease_duration,
136
- renewable: response.auth.renewable, policies: response.auth.policies }
137
- end
138
-
139
- # Authenticate to ALL configured clusters with same credentials
140
- def ldap_login_all(username:, password:)
141
- results = {}
142
- clusters.each do |name, config|
143
- next unless config[:auth_method] == 'ldap'
144
- results[name] = ldap_login(cluster_name: name, username: username, password: password)
145
- rescue StandardError => e
146
- results[name] = { error: e.message }
147
- end
148
- results
149
- end
150
- end
151
- end
152
- ```
153
-
154
- #### Existing Code Changes
155
-
156
- - `Legion::Crypt.start` — if `clusters` present, call `connect_all` instead of `connect_vault`
157
- - `Legion::Crypt::Vault.read/write/get` — route through `vault_client(name)` for cluster-aware reads
158
- - `Legion::Crypt::Vault.connect_vault` — still works for legacy single-cluster config
159
- - `Legion::Crypt::VaultRenewer` — renew tokens for ALL connected clusters
160
- - `Legion::Settings::Resolver` — `vault://` refs gain optional cluster prefix: `vault://prod/secret/data/myapp#password` (falls back to default cluster if no prefix)
161
-
162
- ### 2. LegionIO: `legion config import` / `legionio config import` CLI Command
163
-
164
- New subcommand under `Config`:
165
-
166
- ```
167
- legionio config import <source> # URL or local file path
168
- legion config import <source> # same command available in interactive binary
169
- ```
170
-
171
- #### Behavior
172
-
173
- 1. **Fetch source:**
174
- - If `source` starts with `http://` or `https://` — HTTP GET, follow redirects
175
- - Otherwise — read local file
176
- 2. **Decode payload:**
177
- - Try `JSON.parse(body)` first
178
- - If that fails, try `JSON.parse(Base64.decode64(body))`
179
- - If both fail, error with "not valid JSON or base64-encoded JSON"
180
- 3. **Validate structure:**
181
- - Must be a Hash
182
- - Warn on unrecognized top-level keys (not in known settings keys)
183
- 4. **Write to `~/.legionio/settings/imported.json`:**
184
- - Deep merge with existing imported.json if present
185
- - Or overwrite with `--force`
186
- 5. **Display summary:**
187
- - Which settings sections were imported (crypt, transport, cache, etc.)
188
- - How many vault clusters configured
189
- - Remind user to run `legion` for onboarding vault auth
190
-
191
- #### Example Config File
192
-
193
- ```json
194
- {
195
- "crypt": {
196
- "vault": {
197
- "default": "prod",
198
- "clusters": {
199
- "dev": { "address": "vault-dev.uhg.com", "port": 8200, "protocol": "https", "auth_method": "ldap" },
200
- "test": { "address": "vault-test.uhg.com", "port": 8200, "protocol": "https", "auth_method": "ldap" },
201
- "stage": { "address": "vault-stage.uhg.com", "port": 8200, "protocol": "https", "auth_method": "ldap" },
202
- "prod": { "address": "vault.uhg.com", "port": 8200, "protocol": "https", "auth_method": "ldap" }
203
- }
204
- }
205
- },
206
- "transport": {
207
- "host": "rabbitmq.uhg.com",
208
- "port": 5672,
209
- "vhost": "legion"
210
- },
211
- "cache": {
212
- "driver": "dalli",
213
- "servers": ["memcached.uhg.com:11211"]
214
- }
215
- }
216
- ```
217
-
218
- ### 3. legion-tty: Onboarding Vault Auth Step
219
-
220
- After the wizard (name + LLM providers), before the reveal box:
221
-
222
- ```
223
- [digital rain]
224
- [intro - kerberos identity, github quick]
225
- [wizard - name, LLM providers]
226
- [NEW: vault auth prompt]
227
- [reveal box - now includes vault cluster status]
228
- ```
229
-
230
- #### Flow
231
-
232
- 1. Check if any vault clusters are configured in settings
233
- 2. If none, skip entirely
234
- 3. If clusters exist, ask: "I found N Vault clusters. Connect now?" (TTY::Prompt confirm)
235
- 4. If yes:
236
- - Default username = kerberos `samaccountname` (from `@kerberos_identity[:samaccountname]`), fallback to `ENV['USER']`
237
- - Ask: "Username:" with default pre-filled (TTY::Prompt ask)
238
- - Ask: "Password:" with `echo: false` (hidden input)
239
- - For each LDAP-configured cluster, attempt `Legion::Crypt.ldap_login`
240
- - Show green checkmark / red X per cluster with name
241
- 5. Store tokens in memory (settings hash), NOT on disk with the password
242
- 6. Reveal box now shows vault cluster connection status
243
-
244
- #### New Background Probe: Not Needed
245
-
246
- Vault auth requires user interaction (password prompt), so it runs inline after the wizard, not in a background thread.
247
-
248
- ## Alternatives Considered
249
-
250
- **Use lex-vault instead of vault gem for multi-cluster:** lex-vault's Faraday-based client is simpler and already supports per-instance address/token/namespace. Could replace the `vault` gem dependency in legion-crypt entirely. Deferred — not a requirement for this iteration but a good future optimization.
251
-
252
- **Kerberos auth for Vault:** Not a default Vault auth method. Would require a custom Vault plugin. Deferred.
253
-
254
- **Store tokens on disk:** Vault tokens are renewable and short-lived. Storing them risks stale tokens. Better to re-auth on each `legion` startup if needed. Could add optional token caching later.
255
-
256
- ## Constraints
257
-
258
- - LDAP password is NEVER written to disk or settings files
259
- - Vault tokens are stored in-memory only during the session
260
- - `vault://` resolver must remain backward compatible (no cluster prefix = default cluster)
261
- - Single-cluster config (`crypt.vault.address`) must continue to work unchanged
262
- - Config import file is plain JSON, no wrapper format
263
- - HTTP sources must handle both raw JSON and base64-encoded JSON
264
-
265
- ## Repos Affected
266
-
267
- | Repo | Changes |
268
- |------|---------|
269
- | `legion-crypt` | `VaultCluster` module, `LdapAuth` module, multi-cluster settings, `VaultRenewer` update, backward compat |
270
- | `LegionIO` | `config import` CLI command (both binaries), HTTP fetch + base64 detection |
271
- | `legion-tty` | Onboarding vault auth step after wizard |
272
- | `legion-settings` | `Resolver` update for cluster-prefixed `vault://` refs (optional, can defer) |