legionio 1.4.62 → 1.4.63
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 +4 -4
- data/CHANGELOG.md +8 -0
- data/docs/plans/2026-03-18-config-import-vault-multicluster-design.md +272 -0
- data/docs/plans/2026-03-18-config-import-vault-multicluster-implementation.md +833 -0
- data/lib/legion/cli/config_command.rb +23 -0
- data/lib/legion/cli/config_import.rb +87 -0
- data/lib/legion/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 577e7caee9c14f38ce12e36d35f8a92fe4d66d33c7d62dbde89c8581575dc961
|
|
4
|
+
data.tar.gz: 0165ac8ccd3b32b48cce36a7188669ceb4254b344be99243a1e08fca77235a9f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8192110eaf2b6f11cc38852448feade2999655706c657f2ed0ff78cd8fe698994c78443f90444d4ea0510060d66d6f679ebd6f58a9f9b46d42ef66352413434f
|
|
7
|
+
data.tar.gz: e807e335d274d228fac57bd6761380af2508f5c98cb40b8d4285ae9da9740cd10b1b592b3d3cad21e6a961f9f8cda05196fb5a35dee0a0ae18d6a6b969ddeb7f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Legion Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.63] - 2026-03-18
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `legionio config import SOURCE` command for importing config from URL or local file
|
|
7
|
+
- Supports raw JSON and base64-encoded JSON payloads
|
|
8
|
+
- Deep merges with existing `~/.legionio/settings/imported.json` (or `--force` to overwrite)
|
|
9
|
+
- Displays imported sections and vault cluster count
|
|
10
|
+
|
|
3
11
|
## [1.4.62] - 2026-03-18
|
|
4
12
|
|
|
5
13
|
### Added
|
|
@@ -0,0 +1,272 @@
|
|
|
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) |
|
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
# Config Import + Multi-Cluster Vault Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add multi-cluster Vault support to legion-crypt, a `config import` CLI command, and onboarding Vault LDAP auth in legion-tty.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Three repos changed independently. legion-crypt lands first (prerequisite), then LegionIO CLI and legion-tty can be done in parallel.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby, vault gem, Faraday (for LDAP HTTP auth), TTY::Prompt (hidden password input)
|
|
10
|
+
|
|
11
|
+
**Design Doc:** `docs/plans/2026-03-18-config-import-vault-multicluster-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Phase 1: legion-crypt Multi-Cluster Vault (prerequisite)
|
|
16
|
+
|
|
17
|
+
### Task 1: Multi-Cluster Settings Schema
|
|
18
|
+
|
|
19
|
+
**Files:**
|
|
20
|
+
- Modify: `legion-crypt/lib/legion/crypt/settings.rb`
|
|
21
|
+
- Test: `legion-crypt/spec/legion/settings_spec.rb`
|
|
22
|
+
|
|
23
|
+
**Step 1: Write the failing test**
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# spec/legion/settings_spec.rb
|
|
27
|
+
describe 'vault defaults' do
|
|
28
|
+
it 'includes clusters hash' do
|
|
29
|
+
expect(vault[:clusters]).to eq({})
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'includes default key' do
|
|
33
|
+
expect(vault[:default]).to be_nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Step 2: Run test to verify it fails**
|
|
39
|
+
|
|
40
|
+
Run: `cd legion-crypt && bundle exec rspec spec/legion/settings_spec.rb -v`
|
|
41
|
+
Expected: FAIL — no `:clusters` or `:default` key in vault defaults
|
|
42
|
+
|
|
43
|
+
**Step 3: Write minimal implementation**
|
|
44
|
+
|
|
45
|
+
Add to `Legion::Crypt::Settings.vault`:
|
|
46
|
+
```ruby
|
|
47
|
+
def self.vault
|
|
48
|
+
{
|
|
49
|
+
enabled: !Gem::Specification.find_by_name('vault').nil?,
|
|
50
|
+
protocol: 'http',
|
|
51
|
+
address: 'localhost',
|
|
52
|
+
port: 8200,
|
|
53
|
+
token: ENV['VAULT_DEV_ROOT_TOKEN_ID'] || ENV['VAULT_TOKEN_ID'] || nil,
|
|
54
|
+
connected: false,
|
|
55
|
+
renewer_time: 5,
|
|
56
|
+
renewer: true,
|
|
57
|
+
push_cluster_secret: true,
|
|
58
|
+
read_cluster_secret: true,
|
|
59
|
+
kv_path: ENV['LEGION_VAULT_KV_PATH'] || 'legion',
|
|
60
|
+
leases: {},
|
|
61
|
+
default: nil,
|
|
62
|
+
clusters: {}
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Step 4: Run test to verify it passes**
|
|
68
|
+
|
|
69
|
+
Run: `cd legion-crypt && bundle exec rspec spec/legion/settings_spec.rb -v`
|
|
70
|
+
Expected: PASS
|
|
71
|
+
|
|
72
|
+
**Step 5: Commit**
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
git add lib/legion/crypt/settings.rb spec/legion/settings_spec.rb
|
|
76
|
+
git commit -m "add clusters and default keys to vault settings schema"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Task 2: VaultCluster Module
|
|
80
|
+
|
|
81
|
+
**Files:**
|
|
82
|
+
- Create: `legion-crypt/lib/legion/crypt/vault_cluster.rb`
|
|
83
|
+
- Test: `legion-crypt/spec/legion/vault_cluster_spec.rb`
|
|
84
|
+
|
|
85
|
+
**Step 1: Write the failing test**
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
# spec/legion/vault_cluster_spec.rb
|
|
89
|
+
require 'spec_helper'
|
|
90
|
+
require 'legion/crypt/vault_cluster'
|
|
91
|
+
|
|
92
|
+
RSpec.describe Legion::Crypt::VaultCluster do
|
|
93
|
+
let(:test_obj) { Object.new.extend(described_class) }
|
|
94
|
+
|
|
95
|
+
before do
|
|
96
|
+
allow(test_obj).to receive(:vault_settings).and_return({
|
|
97
|
+
default: 'prod',
|
|
98
|
+
clusters: {
|
|
99
|
+
dev: { address: 'vault-dev.example.com', port: 8200, protocol: 'https', token: nil },
|
|
100
|
+
prod: { address: 'vault.example.com', port: 8200, protocol: 'https', token: 'hvs.abc123' }
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
describe '#default_cluster_name' do
|
|
106
|
+
it 'returns the configured default' do
|
|
107
|
+
expect(test_obj.default_cluster_name).to eq(:prod)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
describe '#cluster' do
|
|
112
|
+
it 'returns default cluster when no name given' do
|
|
113
|
+
expect(test_obj.cluster[:address]).to eq('vault.example.com')
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'returns named cluster' do
|
|
117
|
+
expect(test_obj.cluster(:dev)[:address]).to eq('vault-dev.example.com')
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'returns nil for unknown cluster' do
|
|
121
|
+
expect(test_obj.cluster(:unknown)).to be_nil
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
describe '#clusters' do
|
|
126
|
+
it 'returns all clusters' do
|
|
127
|
+
expect(test_obj.clusters.keys).to contain_exactly(:dev, :prod)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
describe '#vault_client' do
|
|
132
|
+
it 'returns a Vault::Client for the default cluster' do
|
|
133
|
+
client = test_obj.vault_client
|
|
134
|
+
expect(client).to be_a(::Vault::Client)
|
|
135
|
+
expect(client.address).to eq('https://vault.example.com:8200')
|
|
136
|
+
expect(client.token).to eq('hvs.abc123')
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'returns a Vault::Client for a named cluster' do
|
|
140
|
+
client = test_obj.vault_client(:dev)
|
|
141
|
+
expect(client.address).to eq('https://vault-dev.example.com:8200')
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'memoizes clients per cluster name' do
|
|
145
|
+
client1 = test_obj.vault_client(:prod)
|
|
146
|
+
client2 = test_obj.vault_client(:prod)
|
|
147
|
+
expect(client1).to equal(client2)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe '#connected_clusters' do
|
|
152
|
+
it 'returns clusters that have tokens' do
|
|
153
|
+
expect(test_obj.connected_clusters.keys).to eq([:prod])
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Step 2: Run test to verify it fails**
|
|
160
|
+
|
|
161
|
+
Run: `cd legion-crypt && bundle exec rspec spec/legion/vault_cluster_spec.rb -v`
|
|
162
|
+
Expected: FAIL — `Legion::Crypt::VaultCluster` not defined
|
|
163
|
+
|
|
164
|
+
**Step 3: Write minimal implementation**
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# lib/legion/crypt/vault_cluster.rb
|
|
168
|
+
# frozen_string_literal: true
|
|
169
|
+
|
|
170
|
+
require 'vault'
|
|
171
|
+
|
|
172
|
+
module Legion
|
|
173
|
+
module Crypt
|
|
174
|
+
module VaultCluster
|
|
175
|
+
def vault_client(name = nil)
|
|
176
|
+
name = (name || default_cluster_name).to_sym
|
|
177
|
+
@vault_clients ||= {}
|
|
178
|
+
@vault_clients[name] ||= build_vault_client(clusters[name])
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def cluster(name = nil)
|
|
182
|
+
name = (name || default_cluster_name).to_sym
|
|
183
|
+
clusters[name]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def default_cluster_name
|
|
187
|
+
(vault_settings[:default] || clusters.keys.first).to_sym
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def clusters
|
|
191
|
+
vault_settings[:clusters] || {}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def connected_clusters
|
|
195
|
+
clusters.select { |_, config| config[:token] }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def connect_all_clusters
|
|
199
|
+
results = {}
|
|
200
|
+
clusters.each do |name, config|
|
|
201
|
+
next unless config[:token]
|
|
202
|
+
|
|
203
|
+
client = vault_client(name)
|
|
204
|
+
config[:connected] = client.sys.health_status.initialized?
|
|
205
|
+
results[name] = config[:connected]
|
|
206
|
+
rescue StandardError => e
|
|
207
|
+
config[:connected] = false
|
|
208
|
+
results[name] = false
|
|
209
|
+
log_vault_error(name, e)
|
|
210
|
+
end
|
|
211
|
+
results
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def build_vault_client(config)
|
|
217
|
+
return nil unless config.is_a?(Hash)
|
|
218
|
+
|
|
219
|
+
client = ::Vault::Client.new(
|
|
220
|
+
address: "#{config[:protocol]}://#{config[:address]}:#{config[:port]}",
|
|
221
|
+
token: config[:token]
|
|
222
|
+
)
|
|
223
|
+
client.namespace = config[:namespace] if config[:namespace]
|
|
224
|
+
client
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def log_vault_error(name, error)
|
|
228
|
+
if defined?(Legion::Logging)
|
|
229
|
+
Legion::Logging.error("Vault cluster #{name}: #{error.message}")
|
|
230
|
+
else
|
|
231
|
+
warn("Vault cluster #{name}: #{error.message}")
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
**Step 4: Run test to verify it passes**
|
|
240
|
+
|
|
241
|
+
Run: `cd legion-crypt && bundle exec rspec spec/legion/vault_cluster_spec.rb -v`
|
|
242
|
+
Expected: PASS
|
|
243
|
+
|
|
244
|
+
**Step 5: Commit**
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
git add lib/legion/crypt/vault_cluster.rb spec/legion/vault_cluster_spec.rb
|
|
248
|
+
git commit -m "add VaultCluster module for multi-cluster vault connections"
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Task 3: LdapAuth Module
|
|
252
|
+
|
|
253
|
+
**Files:**
|
|
254
|
+
- Create: `legion-crypt/lib/legion/crypt/ldap_auth.rb`
|
|
255
|
+
- Test: `legion-crypt/spec/legion/ldap_auth_spec.rb`
|
|
256
|
+
|
|
257
|
+
**Step 1: Write the failing test**
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
# spec/legion/ldap_auth_spec.rb
|
|
261
|
+
require 'spec_helper'
|
|
262
|
+
require 'legion/crypt/vault_cluster'
|
|
263
|
+
require 'legion/crypt/ldap_auth'
|
|
264
|
+
|
|
265
|
+
RSpec.describe Legion::Crypt::LdapAuth do
|
|
266
|
+
let(:test_obj) do
|
|
267
|
+
obj = Object.new
|
|
268
|
+
obj.extend(Legion::Crypt::VaultCluster)
|
|
269
|
+
obj.extend(described_class)
|
|
270
|
+
obj
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
let(:clusters_config) do
|
|
274
|
+
{
|
|
275
|
+
default: 'prod',
|
|
276
|
+
clusters: {
|
|
277
|
+
prod: { address: 'vault.example.com', port: 8200, protocol: 'https', auth_method: 'ldap', token: nil },
|
|
278
|
+
stage: { address: 'vault-stage.example.com', port: 8200, protocol: 'https', auth_method: 'ldap', token: nil },
|
|
279
|
+
dev: { address: 'vault-dev.example.com', port: 8200, protocol: 'https', auth_method: 'token', token: 'hvs.existing' }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
before do
|
|
285
|
+
allow(test_obj).to receive(:vault_settings).and_return(clusters_config)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
describe '#ldap_login' do
|
|
289
|
+
it 'authenticates to a cluster and stores the token' do
|
|
290
|
+
mock_auth = double(client_token: 'hvs.newtoken', lease_duration: 3600, renewable: true, policies: ['default'])
|
|
291
|
+
mock_secret = double(auth: mock_auth)
|
|
292
|
+
mock_logical = double(write: mock_secret)
|
|
293
|
+
mock_client = instance_double(::Vault::Client, logical: mock_logical)
|
|
294
|
+
allow(test_obj).to receive(:vault_client).with(:prod).and_return(mock_client)
|
|
295
|
+
|
|
296
|
+
result = test_obj.ldap_login(cluster_name: :prod, username: 'jdoe', password: 's3cret')
|
|
297
|
+
expect(result[:token]).to eq('hvs.newtoken')
|
|
298
|
+
expect(result[:lease_duration]).to eq(3600)
|
|
299
|
+
expect(clusters_config[:clusters][:prod][:token]).to eq('hvs.newtoken')
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
describe '#ldap_login_all' do
|
|
304
|
+
it 'authenticates to all LDAP clusters and skips non-LDAP ones' do
|
|
305
|
+
mock_auth = double(client_token: 'hvs.tok', lease_duration: 3600, renewable: true, policies: ['default'])
|
|
306
|
+
mock_secret = double(auth: mock_auth)
|
|
307
|
+
mock_logical = double(write: mock_secret)
|
|
308
|
+
mock_client = instance_double(::Vault::Client, logical: mock_logical)
|
|
309
|
+
allow(test_obj).to receive(:vault_client).and_return(mock_client)
|
|
310
|
+
|
|
311
|
+
results = test_obj.ldap_login_all(username: 'jdoe', password: 's3cret')
|
|
312
|
+
expect(results.keys).to contain_exactly(:prod, :stage)
|
|
313
|
+
expect(results[:prod][:token]).to eq('hvs.tok')
|
|
314
|
+
expect(results[:stage][:token]).to eq('hvs.tok')
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
it 'captures errors per cluster without stopping' do
|
|
318
|
+
allow(test_obj).to receive(:vault_client).and_raise(StandardError.new('connection refused'))
|
|
319
|
+
|
|
320
|
+
results = test_obj.ldap_login_all(username: 'jdoe', password: 's3cret')
|
|
321
|
+
expect(results[:prod][:error]).to eq('connection refused')
|
|
322
|
+
expect(results[:stage][:error]).to eq('connection refused')
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**Step 2: Run test to verify it fails**
|
|
329
|
+
|
|
330
|
+
Run: `cd legion-crypt && bundle exec rspec spec/legion/ldap_auth_spec.rb -v`
|
|
331
|
+
Expected: FAIL — `Legion::Crypt::LdapAuth` not defined
|
|
332
|
+
|
|
333
|
+
**Step 3: Write minimal implementation**
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
# lib/legion/crypt/ldap_auth.rb
|
|
337
|
+
# frozen_string_literal: true
|
|
338
|
+
|
|
339
|
+
module Legion
|
|
340
|
+
module Crypt
|
|
341
|
+
module LdapAuth
|
|
342
|
+
def ldap_login(cluster_name:, username:, password:)
|
|
343
|
+
client = vault_client(cluster_name)
|
|
344
|
+
secret = client.logical.write("auth/ldap/login/#{username}", password: password)
|
|
345
|
+
auth = secret.auth
|
|
346
|
+
token = auth.client_token
|
|
347
|
+
|
|
348
|
+
clusters[cluster_name][:token] = token
|
|
349
|
+
clusters[cluster_name][:connected] = true
|
|
350
|
+
|
|
351
|
+
{ token: token, lease_duration: auth.lease_duration,
|
|
352
|
+
renewable: auth.renewable, policies: auth.policies }
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def ldap_login_all(username:, password:)
|
|
356
|
+
results = {}
|
|
357
|
+
clusters.each do |name, config|
|
|
358
|
+
next unless config[:auth_method] == 'ldap'
|
|
359
|
+
|
|
360
|
+
results[name] = ldap_login(cluster_name: name, username: username, password: password)
|
|
361
|
+
rescue StandardError => e
|
|
362
|
+
results[name] = { error: e.message }
|
|
363
|
+
end
|
|
364
|
+
results
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
**Step 4: Run test to verify it passes**
|
|
372
|
+
|
|
373
|
+
Run: `cd legion-crypt && bundle exec rspec spec/legion/ldap_auth_spec.rb -v`
|
|
374
|
+
Expected: PASS
|
|
375
|
+
|
|
376
|
+
**Step 5: Commit**
|
|
377
|
+
|
|
378
|
+
```bash
|
|
379
|
+
git add lib/legion/crypt/ldap_auth.rb spec/legion/ldap_auth_spec.rb
|
|
380
|
+
git commit -m "add LdapAuth module for vault LDAP authentication"
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Task 4: Wire Multi-Cluster into Legion::Crypt.start
|
|
384
|
+
|
|
385
|
+
**Files:**
|
|
386
|
+
- Modify: `legion-crypt/lib/legion/crypt.rb`
|
|
387
|
+
- Modify: `legion-crypt/lib/legion/crypt/vault.rb`
|
|
388
|
+
- Test: `legion-crypt/spec/legion/crypt_spec.rb`
|
|
389
|
+
|
|
390
|
+
**Step 1: Write the failing test**
|
|
391
|
+
|
|
392
|
+
```ruby
|
|
393
|
+
# Add to spec/legion/crypt_spec.rb
|
|
394
|
+
describe '.cluster' do
|
|
395
|
+
it 'delegates to VaultCluster#cluster' do
|
|
396
|
+
expect(Legion::Crypt).to respond_to(:cluster)
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
describe '.ldap_login_all' do
|
|
401
|
+
it 'delegates to LdapAuth#ldap_login_all' do
|
|
402
|
+
expect(Legion::Crypt).to respond_to(:ldap_login_all)
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
**Step 2: Run test to verify it fails**
|
|
408
|
+
|
|
409
|
+
Run: `cd legion-crypt && bundle exec rspec spec/legion/crypt_spec.rb -v`
|
|
410
|
+
Expected: FAIL — `Legion::Crypt.cluster` not defined
|
|
411
|
+
|
|
412
|
+
**Step 3: Write minimal implementation**
|
|
413
|
+
|
|
414
|
+
In `lib/legion/crypt.rb`, add:
|
|
415
|
+
```ruby
|
|
416
|
+
require_relative 'crypt/vault_cluster'
|
|
417
|
+
require_relative 'crypt/ldap_auth'
|
|
418
|
+
|
|
419
|
+
module Legion
|
|
420
|
+
module Crypt
|
|
421
|
+
extend VaultCluster
|
|
422
|
+
extend LdapAuth
|
|
423
|
+
|
|
424
|
+
def self.vault_settings
|
|
425
|
+
Legion::Settings[:crypt][:vault]
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Update start to handle multi-cluster
|
|
429
|
+
def self.start
|
|
430
|
+
# ... existing code ...
|
|
431
|
+
if vault_settings[:clusters]&.any?
|
|
432
|
+
connect_all_clusters
|
|
433
|
+
else
|
|
434
|
+
connect_vault # legacy single-cluster path
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
**Step 4: Run test to verify it passes**
|
|
442
|
+
|
|
443
|
+
Run: `cd legion-crypt && bundle exec rspec spec/legion/crypt_spec.rb -v`
|
|
444
|
+
Expected: PASS
|
|
445
|
+
|
|
446
|
+
**Step 5: Run full suite and commit**
|
|
447
|
+
|
|
448
|
+
```bash
|
|
449
|
+
cd legion-crypt && bundle exec rspec && bundle exec rubocop -A && bundle exec rubocop
|
|
450
|
+
git add lib/legion/crypt.rb lib/legion/crypt/vault.rb spec/legion/crypt_spec.rb
|
|
451
|
+
git commit -m "wire multi-cluster vault into Legion::Crypt.start with backward compat"
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Task 5: Update VaultRenewer for Multi-Cluster
|
|
455
|
+
|
|
456
|
+
**Files:**
|
|
457
|
+
- Modify: `legion-crypt/lib/legion/crypt/vault_renewer.rb`
|
|
458
|
+
- Test: `legion-crypt/spec/legion/vault_renewer_spec.rb`
|
|
459
|
+
|
|
460
|
+
Renewer must iterate `connected_clusters` and renew each token. If no clusters are configured, fall back to single-cluster renewal (existing behavior).
|
|
461
|
+
|
|
462
|
+
### Task 6: Version Bump + Pipeline
|
|
463
|
+
|
|
464
|
+
**Files:**
|
|
465
|
+
- Modify: `legion-crypt/lib/legion/crypt/version.rb` (bump to 1.4.4)
|
|
466
|
+
- Modify: `legion-crypt/CHANGELOG.md`
|
|
467
|
+
|
|
468
|
+
Run full pre-push pipeline: rspec, rubocop -A, rubocop, version bump, changelog, push.
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## Phase 2: LegionIO `config import` CLI Command
|
|
473
|
+
|
|
474
|
+
### Task 7: Config Import Command
|
|
475
|
+
|
|
476
|
+
**Files:**
|
|
477
|
+
- Create: `LegionIO/lib/legion/cli/config_import.rb`
|
|
478
|
+
- Modify: `LegionIO/lib/legion/cli/config_command.rb` (register subcommand)
|
|
479
|
+
- Test: `LegionIO/spec/legion/cli/config_import_spec.rb`
|
|
480
|
+
|
|
481
|
+
**Step 1: Write the failing test**
|
|
482
|
+
|
|
483
|
+
```ruby
|
|
484
|
+
# spec/legion/cli/config_import_spec.rb
|
|
485
|
+
require 'spec_helper'
|
|
486
|
+
require 'legion/cli/config_import'
|
|
487
|
+
|
|
488
|
+
RSpec.describe Legion::CLI::ConfigImport do
|
|
489
|
+
describe '.parse_payload' do
|
|
490
|
+
it 'parses raw JSON' do
|
|
491
|
+
result = described_class.parse_payload('{"crypt": {"vault": {}}}')
|
|
492
|
+
expect(result).to eq({ crypt: { vault: {} } })
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
it 'parses base64-encoded JSON' do
|
|
496
|
+
encoded = Base64.strict_encode64('{"transport": {"host": "rmq.example.com"}}')
|
|
497
|
+
result = described_class.parse_payload(encoded)
|
|
498
|
+
expect(result[:transport][:host]).to eq('rmq.example.com')
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
it 'raises on invalid input' do
|
|
502
|
+
expect { described_class.parse_payload('not json at all %%%') }.to raise_error(Legion::CLI::Error)
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
describe '.fetch_source' do
|
|
507
|
+
it 'reads a local file' do
|
|
508
|
+
tmpfile = Tempfile.new(['config', '.json'])
|
|
509
|
+
tmpfile.write('{"cache": {"driver": "dalli"}}')
|
|
510
|
+
tmpfile.close
|
|
511
|
+
result = described_class.fetch_source(tmpfile.path)
|
|
512
|
+
expect(result).to include('"cache"')
|
|
513
|
+
tmpfile.unlink
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
**Step 2: Run test to verify it fails**
|
|
520
|
+
|
|
521
|
+
Run: `cd LegionIO && bundle exec rspec spec/legion/cli/config_import_spec.rb -v`
|
|
522
|
+
Expected: FAIL — file doesn't exist
|
|
523
|
+
|
|
524
|
+
**Step 3: Write minimal implementation**
|
|
525
|
+
|
|
526
|
+
```ruby
|
|
527
|
+
# lib/legion/cli/config_import.rb
|
|
528
|
+
# frozen_string_literal: true
|
|
529
|
+
|
|
530
|
+
require 'base64'
|
|
531
|
+
require 'net/http'
|
|
532
|
+
require 'uri'
|
|
533
|
+
require 'fileutils'
|
|
534
|
+
|
|
535
|
+
module Legion
|
|
536
|
+
module CLI
|
|
537
|
+
class ConfigImport
|
|
538
|
+
SETTINGS_DIR = File.expand_path('~/.legionio/settings')
|
|
539
|
+
IMPORT_FILE = 'imported.json'
|
|
540
|
+
|
|
541
|
+
def self.fetch_source(source)
|
|
542
|
+
if source.match?(%r{\Ahttps?://})
|
|
543
|
+
fetch_http(source)
|
|
544
|
+
else
|
|
545
|
+
raise CLI::Error, "File not found: #{source}" unless File.exist?(source)
|
|
546
|
+
|
|
547
|
+
File.read(source)
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def self.fetch_http(url)
|
|
552
|
+
uri = URI.parse(url)
|
|
553
|
+
response = Net::HTTP.get_response(uri)
|
|
554
|
+
raise CLI::Error, "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
555
|
+
|
|
556
|
+
response.body
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def self.parse_payload(body)
|
|
560
|
+
# Try raw JSON first
|
|
561
|
+
parsed = ::JSON.parse(body, symbolize_names: true)
|
|
562
|
+
raise CLI::Error, 'Config must be a JSON object' unless parsed.is_a?(Hash)
|
|
563
|
+
|
|
564
|
+
parsed
|
|
565
|
+
rescue ::JSON::ParserError
|
|
566
|
+
# Try base64-decoded JSON
|
|
567
|
+
begin
|
|
568
|
+
decoded = Base64.decode64(body)
|
|
569
|
+
parsed = ::JSON.parse(decoded, symbolize_names: true)
|
|
570
|
+
raise CLI::Error, 'Config must be a JSON object' unless parsed.is_a?(Hash)
|
|
571
|
+
|
|
572
|
+
parsed
|
|
573
|
+
rescue ::JSON::ParserError
|
|
574
|
+
raise CLI::Error, 'Source is not valid JSON or base64-encoded JSON'
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def self.write_config(config, force: false)
|
|
579
|
+
FileUtils.mkdir_p(SETTINGS_DIR)
|
|
580
|
+
path = File.join(SETTINGS_DIR, IMPORT_FILE)
|
|
581
|
+
|
|
582
|
+
if File.exist?(path) && !force
|
|
583
|
+
existing = ::JSON.parse(File.read(path), symbolize_names: true)
|
|
584
|
+
config = deep_merge(existing, config)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
File.write(path, ::JSON.pretty_generate(config))
|
|
588
|
+
path
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def self.deep_merge(base, overlay)
|
|
592
|
+
base.merge(overlay) do |_key, old_val, new_val|
|
|
593
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
594
|
+
deep_merge(old_val, new_val)
|
|
595
|
+
else
|
|
596
|
+
new_val
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def self.summary(config)
|
|
602
|
+
sections = config.keys.map(&:to_s)
|
|
603
|
+
vault_clusters = config.dig(:crypt, :vault, :clusters)&.keys&.map(&:to_s) || []
|
|
604
|
+
{ sections: sections, vault_clusters: vault_clusters }
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
**Step 4: Run test to verify it passes**
|
|
612
|
+
|
|
613
|
+
Run: `cd LegionIO && bundle exec rspec spec/legion/cli/config_import_spec.rb -v`
|
|
614
|
+
Expected: PASS
|
|
615
|
+
|
|
616
|
+
**Step 5: Commit**
|
|
617
|
+
|
|
618
|
+
```bash
|
|
619
|
+
git add lib/legion/cli/config_import.rb spec/legion/cli/config_import_spec.rb
|
|
620
|
+
git commit -m "add config import utility for URL and local file sources"
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
### Task 8: Wire Import into Config Subcommand
|
|
624
|
+
|
|
625
|
+
**Files:**
|
|
626
|
+
- Modify: `LegionIO/lib/legion/cli/config_command.rb`
|
|
627
|
+
|
|
628
|
+
Add `import` subcommand to Config Thor class:
|
|
629
|
+
```ruby
|
|
630
|
+
desc 'import SOURCE', 'Import configuration from a URL or local file'
|
|
631
|
+
option :force, type: :boolean, default: false, desc: 'Overwrite existing imported config'
|
|
632
|
+
def import(source)
|
|
633
|
+
out = formatter
|
|
634
|
+
require_relative 'config_import'
|
|
635
|
+
|
|
636
|
+
out.info("Fetching config from #{source}...")
|
|
637
|
+
body = ConfigImport.fetch_source(source)
|
|
638
|
+
config = ConfigImport.parse_payload(body)
|
|
639
|
+
path = ConfigImport.write_config(config, force: options[:force])
|
|
640
|
+
summary = ConfigImport.summary(config)
|
|
641
|
+
|
|
642
|
+
out.success("Config written to #{path}")
|
|
643
|
+
out.info("Sections: #{summary[:sections].join(', ')}")
|
|
644
|
+
if summary[:vault_clusters].any?
|
|
645
|
+
out.info("Vault clusters: #{summary[:vault_clusters].join(', ')}")
|
|
646
|
+
out.info("Run 'legion' to authenticate via LDAP during onboarding")
|
|
647
|
+
end
|
|
648
|
+
rescue CLI::Error => e
|
|
649
|
+
formatter.error(e.message)
|
|
650
|
+
raise SystemExit, 1
|
|
651
|
+
end
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
**Step 1: Write test, Step 2: Verify fail, Step 3: Implement, Step 4: Verify pass**
|
|
655
|
+
|
|
656
|
+
**Step 5: Commit**
|
|
657
|
+
|
|
658
|
+
```bash
|
|
659
|
+
git add lib/legion/cli/config_command.rb
|
|
660
|
+
git commit -m "add 'config import' subcommand for URL and local file config import"
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
### Task 9: Version Bump + Pipeline for LegionIO
|
|
664
|
+
|
|
665
|
+
Run full pre-push pipeline. Bump to 1.4.63.
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## Phase 3: legion-tty Onboarding Vault Auth
|
|
670
|
+
|
|
671
|
+
### Task 10: VaultAuth Background-Free Prompt
|
|
672
|
+
|
|
673
|
+
**Files:**
|
|
674
|
+
- Create: `legion-tty/lib/legion/tty/screens/vault_auth.rb` (extracted helper, not a full screen)
|
|
675
|
+
- Modify: `legion-tty/lib/legion/tty/screens/onboarding.rb`
|
|
676
|
+
- Test: `legion-tty/spec/legion/tty/screens/onboarding_spec.rb`
|
|
677
|
+
|
|
678
|
+
**Step 1: Write the failing test**
|
|
679
|
+
|
|
680
|
+
```ruby
|
|
681
|
+
# Add to onboarding_spec.rb
|
|
682
|
+
describe '#run_vault_auth' do
|
|
683
|
+
context 'when no vault clusters configured' do
|
|
684
|
+
it 'skips vault auth entirely' do
|
|
685
|
+
allow(screen).to receive(:vault_clusters_configured?).and_return(false)
|
|
686
|
+
expect(wizard).not_to receive(:confirm)
|
|
687
|
+
screen.send(:run_vault_auth)
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
context 'when vault clusters configured' do
|
|
692
|
+
before do
|
|
693
|
+
allow(screen).to receive(:vault_clusters_configured?).and_return(true)
|
|
694
|
+
allow(screen).to receive(:vault_cluster_count).and_return(3)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
it 'asks user if they want to connect' do
|
|
698
|
+
allow(wizard).to receive(:confirm).and_return(false)
|
|
699
|
+
screen.send(:run_vault_auth)
|
|
700
|
+
expect(wizard).to have_received(:confirm).with(/3 Vault clusters/)
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
**Step 2: Run test to verify it fails**
|
|
707
|
+
|
|
708
|
+
Run: `cd legion-tty && bundle exec rspec spec/legion/tty/screens/onboarding_spec.rb -v`
|
|
709
|
+
Expected: FAIL
|
|
710
|
+
|
|
711
|
+
**Step 3: Write minimal implementation**
|
|
712
|
+
|
|
713
|
+
Add to `onboarding.rb`:
|
|
714
|
+
|
|
715
|
+
```ruby
|
|
716
|
+
def run_vault_auth
|
|
717
|
+
return unless vault_clusters_configured?
|
|
718
|
+
|
|
719
|
+
count = vault_cluster_count
|
|
720
|
+
typed_output("I found #{count} Vault cluster#{'s' if count != 1}.")
|
|
721
|
+
@output.puts
|
|
722
|
+
return unless @wizard.confirm("Connect now?")
|
|
723
|
+
|
|
724
|
+
username = default_vault_username
|
|
725
|
+
username = @wizard.ask_with_default('Username:', username)
|
|
726
|
+
password = @wizard.ask_secret('Password:')
|
|
727
|
+
|
|
728
|
+
typed_output('Authenticating...')
|
|
729
|
+
@output.puts
|
|
730
|
+
|
|
731
|
+
results = Legion::Crypt.ldap_login_all(username: username, password: password)
|
|
732
|
+
display_vault_results(results)
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def vault_clusters_configured?
|
|
736
|
+
return false unless defined?(Legion::Crypt)
|
|
737
|
+
|
|
738
|
+
clusters = Legion::Settings.dig(:crypt, :vault, :clusters)
|
|
739
|
+
clusters.is_a?(Hash) && clusters.any?
|
|
740
|
+
rescue StandardError
|
|
741
|
+
false
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def vault_cluster_count
|
|
745
|
+
Legion::Settings.dig(:crypt, :vault, :clusters)&.size || 0
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def default_vault_username
|
|
749
|
+
if @kerberos_identity
|
|
750
|
+
@kerberos_identity[:samaccountname] || @kerberos_identity[:first_name]&.downcase
|
|
751
|
+
else
|
|
752
|
+
ENV.fetch('USER', 'unknown')
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def display_vault_results(results)
|
|
757
|
+
results.each do |name, result|
|
|
758
|
+
if result[:error]
|
|
759
|
+
@output.puts " #{Theme.c(:error, 'X')} #{name}: #{result[:error]}"
|
|
760
|
+
else
|
|
761
|
+
@output.puts " #{Theme.c(:success, 'ok')} #{name}: connected (#{result[:policies]&.size || 0} policies)"
|
|
762
|
+
end
|
|
763
|
+
end
|
|
764
|
+
@output.puts
|
|
765
|
+
sleep 1
|
|
766
|
+
end
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
Wire into `activate` method between `run_wizard` and `collect_background_results`:
|
|
770
|
+
```ruby
|
|
771
|
+
def activate
|
|
772
|
+
start_background_threads
|
|
773
|
+
run_rain unless @skip_rain
|
|
774
|
+
run_intro
|
|
775
|
+
config = run_wizard
|
|
776
|
+
run_vault_auth # <-- NEW
|
|
777
|
+
scan_data, github_data = collect_background_results
|
|
778
|
+
# ...
|
|
779
|
+
end
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
**Step 4: Run test to verify it passes**
|
|
783
|
+
|
|
784
|
+
Run: `cd legion-tty && bundle exec rspec spec/legion/tty/screens/onboarding_spec.rb -v`
|
|
785
|
+
Expected: PASS
|
|
786
|
+
|
|
787
|
+
**Step 5: Commit**
|
|
788
|
+
|
|
789
|
+
```bash
|
|
790
|
+
git add lib/legion/tty/screens/onboarding.rb spec/legion/tty/screens/onboarding_spec.rb
|
|
791
|
+
git commit -m "add vault LDAP auth step to onboarding wizard"
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
### Task 11: WizardPrompt Secret Input
|
|
795
|
+
|
|
796
|
+
**Files:**
|
|
797
|
+
- Modify: `legion-tty/lib/legion/tty/components/wizard_prompt.rb`
|
|
798
|
+
- Test: `legion-tty/spec/legion/tty/components/wizard_prompt_spec.rb`
|
|
799
|
+
|
|
800
|
+
Add `ask_secret` and `ask_with_default` methods to WizardPrompt:
|
|
801
|
+
|
|
802
|
+
```ruby
|
|
803
|
+
def ask_secret(question)
|
|
804
|
+
@prompt.mask(question)
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def ask_with_default(question, default)
|
|
808
|
+
@prompt.ask(question, default: default)
|
|
809
|
+
end
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
### Task 12: Vault Summary in Reveal Box
|
|
813
|
+
|
|
814
|
+
**Files:**
|
|
815
|
+
- Modify: `legion-tty/lib/legion/tty/screens/onboarding.rb`
|
|
816
|
+
|
|
817
|
+
Add `vault_summary_lines` to `build_summary`, showing connected/disconnected vault clusters.
|
|
818
|
+
|
|
819
|
+
### Task 13: Version Bump + Pipeline for legion-tty
|
|
820
|
+
|
|
821
|
+
Bump to 0.2.3. Run full pre-push pipeline.
|
|
822
|
+
|
|
823
|
+
---
|
|
824
|
+
|
|
825
|
+
## Execution Order
|
|
826
|
+
|
|
827
|
+
```
|
|
828
|
+
Task 1-6 (legion-crypt) — FIRST, prerequisite
|
|
829
|
+
Task 7-9 (LegionIO) — after Task 6, can parallel with Tasks 10-13
|
|
830
|
+
Task 10-13 (legion-tty) — after Task 6, can parallel with Tasks 7-9
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
## Recommended Execution: `1 → 2 → 3 → 4 → 5 → 6 → [7-9 || 10-13]`
|
|
@@ -182,6 +182,29 @@ module Legion
|
|
|
182
182
|
raise SystemExit, exit_code if exit_code != 0
|
|
183
183
|
end
|
|
184
184
|
|
|
185
|
+
desc 'import SOURCE', 'Import configuration from a URL or local file'
|
|
186
|
+
option :force, type: :boolean, default: false, desc: 'Overwrite existing imported config'
|
|
187
|
+
def import(source)
|
|
188
|
+
out = formatter
|
|
189
|
+
require_relative 'config_import'
|
|
190
|
+
|
|
191
|
+
out.info("Fetching config from #{source}...")
|
|
192
|
+
body = ConfigImport.fetch_source(source)
|
|
193
|
+
config = ConfigImport.parse_payload(body)
|
|
194
|
+
path = ConfigImport.write_config(config, force: options[:force])
|
|
195
|
+
summary = ConfigImport.summary(config)
|
|
196
|
+
|
|
197
|
+
out.success("Config written to #{path}")
|
|
198
|
+
out.info("Sections: #{summary[:sections].join(', ')}")
|
|
199
|
+
if summary[:vault_clusters].any?
|
|
200
|
+
out.info("Vault clusters: #{summary[:vault_clusters].join(', ')}")
|
|
201
|
+
out.info("Run 'legion' to authenticate via LDAP during onboarding")
|
|
202
|
+
end
|
|
203
|
+
rescue CLI::Error => e
|
|
204
|
+
formatter.error(e.message)
|
|
205
|
+
raise SystemExit, 1
|
|
206
|
+
end
|
|
207
|
+
|
|
185
208
|
no_commands do # rubocop:disable Metrics/BlockLength
|
|
186
209
|
def formatter
|
|
187
210
|
@formatter ||= Output::Formatter.new(
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
require 'json'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module CLI
|
|
11
|
+
module ConfigImport
|
|
12
|
+
SETTINGS_DIR = File.expand_path('~/.legionio/settings')
|
|
13
|
+
IMPORT_FILE = 'imported.json'
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def fetch_source(source)
|
|
18
|
+
if source.match?(%r{\Ahttps?://})
|
|
19
|
+
fetch_http(source)
|
|
20
|
+
else
|
|
21
|
+
raise CLI::Error, "File not found: #{source}" unless File.exist?(source)
|
|
22
|
+
|
|
23
|
+
File.read(source)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fetch_http(url)
|
|
28
|
+
uri = URI.parse(url)
|
|
29
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
30
|
+
http.use_ssl = uri.scheme == 'https'
|
|
31
|
+
http.open_timeout = 10
|
|
32
|
+
http.read_timeout = 10
|
|
33
|
+
request = Net::HTTP::Get.new(uri)
|
|
34
|
+
response = http.request(request)
|
|
35
|
+
raise CLI::Error, "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
36
|
+
|
|
37
|
+
response.body
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parse_payload(body)
|
|
41
|
+
parsed = ::JSON.parse(body, symbolize_names: true)
|
|
42
|
+
raise CLI::Error, 'Config must be a JSON object' unless parsed.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
parsed
|
|
45
|
+
rescue ::JSON::ParserError
|
|
46
|
+
begin
|
|
47
|
+
decoded = Base64.decode64(body)
|
|
48
|
+
parsed = ::JSON.parse(decoded, symbolize_names: true)
|
|
49
|
+
raise CLI::Error, 'Config must be a JSON object' unless parsed.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
parsed
|
|
52
|
+
rescue ::JSON::ParserError
|
|
53
|
+
raise CLI::Error, 'Source is not valid JSON or base64-encoded JSON'
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def write_config(config, force: false)
|
|
58
|
+
FileUtils.mkdir_p(SETTINGS_DIR)
|
|
59
|
+
path = File.join(SETTINGS_DIR, IMPORT_FILE)
|
|
60
|
+
|
|
61
|
+
if File.exist?(path) && !force
|
|
62
|
+
existing = ::JSON.parse(File.read(path), symbolize_names: true)
|
|
63
|
+
config = deep_merge(existing, config)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
File.write(path, ::JSON.pretty_generate(config))
|
|
67
|
+
path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def deep_merge(base, overlay)
|
|
71
|
+
base.merge(overlay) do |_key, old_val, new_val|
|
|
72
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
73
|
+
deep_merge(old_val, new_val)
|
|
74
|
+
else
|
|
75
|
+
new_val
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def summary(config)
|
|
81
|
+
sections = config.keys.map(&:to_s)
|
|
82
|
+
vault_clusters = config.dig(:crypt, :vault, :clusters)&.keys&.map(&:to_s) || []
|
|
83
|
+
{ sections: sections, vault_clusters: vault_clusters }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/legion/version.rb
CHANGED
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.4.
|
|
4
|
+
version: 1.4.63
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -329,6 +329,8 @@ files:
|
|
|
329
329
|
- completions/legionio.bash
|
|
330
330
|
- docker_deploy.rb
|
|
331
331
|
- docs/README.md
|
|
332
|
+
- docs/plans/2026-03-18-config-import-vault-multicluster-design.md
|
|
333
|
+
- docs/plans/2026-03-18-config-import-vault-multicluster-implementation.md
|
|
332
334
|
- docs/plans/2026-03-18-legion-tty-default-cli-design.md
|
|
333
335
|
- docs/plans/2026-03-18-legion-tty-default-cli-implementation.md
|
|
334
336
|
- exe/legion
|
|
@@ -420,6 +422,7 @@ files:
|
|
|
420
422
|
- lib/legion/cli/commit_command.rb
|
|
421
423
|
- lib/legion/cli/completion_command.rb
|
|
422
424
|
- lib/legion/cli/config_command.rb
|
|
425
|
+
- lib/legion/cli/config_import.rb
|
|
423
426
|
- lib/legion/cli/config_scaffold.rb
|
|
424
427
|
- lib/legion/cli/connection.rb
|
|
425
428
|
- lib/legion/cli/cost/data_client.rb
|