walheim 0.1.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 +7 -0
- data/README.md +321 -0
- data/bin/whctl +703 -0
- data/lib/walheim/cluster_resource.rb +166 -0
- data/lib/walheim/config.rb +206 -0
- data/lib/walheim/handler_registry.rb +55 -0
- data/lib/walheim/namespaced_resource.rb +195 -0
- data/lib/walheim/resource.rb +76 -0
- data/lib/walheim/resources/apps.rb +576 -0
- data/lib/walheim/resources/configmaps.rb +48 -0
- data/lib/walheim/resources/namespaces.rb +41 -0
- data/lib/walheim/resources/secrets.rb +50 -0
- data/lib/walheim/sync.rb +60 -0
- data/lib/walheim/version.rb +5 -0
- data/lib/walheim.rb +19 -0
- metadata +105 -0
data/bin/whctl
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'bundler/setup'
|
|
5
|
+
require 'optparse'
|
|
6
|
+
require_relative '../lib/walheim'
|
|
7
|
+
|
|
8
|
+
# Generate help text for a resource's operations
|
|
9
|
+
def generate_resource_help(handler_info)
|
|
10
|
+
handler_class = handler_info[:handler]
|
|
11
|
+
kind_name = handler_info[:name]
|
|
12
|
+
|
|
13
|
+
# Skip if resource doesn't have operation_info (e.g., Namespaces)
|
|
14
|
+
return [] unless handler_class.respond_to?(:operation_info)
|
|
15
|
+
|
|
16
|
+
# Get operation metadata from the resource class
|
|
17
|
+
operation_info = handler_class.operation_info
|
|
18
|
+
|
|
19
|
+
help_lines = []
|
|
20
|
+
|
|
21
|
+
operation_info.each do |operation, metadata|
|
|
22
|
+
# Check if this resource actually supports the operation
|
|
23
|
+
next unless Walheim::HandlerRegistry.supports_operation?(kind_name, operation)
|
|
24
|
+
|
|
25
|
+
# Add each usage line
|
|
26
|
+
metadata[:usage].each do |usage_line|
|
|
27
|
+
help_lines << " #{usage_line.ljust(70)} #{metadata[:description]}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
help_lines
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Generate help for global commands
|
|
35
|
+
def generate_global_help
|
|
36
|
+
[
|
|
37
|
+
" create namespace {name} [--username {user}] [--hostname {host}]".ljust(72) + "Create a new namespace",
|
|
38
|
+
" apply -f {manifest.yaml}".ljust(72) + "Apply resource from file (use -f - for stdin)",
|
|
39
|
+
"",
|
|
40
|
+
"Context commands:",
|
|
41
|
+
" context new {name} --data-dir {path}".ljust(72) + "Create a new context",
|
|
42
|
+
" context list".ljust(72) + "List all contexts",
|
|
43
|
+
" context use {name}".ljust(72) + "Switch to a context",
|
|
44
|
+
" context current".ljust(72) + "Show current context",
|
|
45
|
+
" context delete {name}".ljust(72) + "Delete a context"
|
|
46
|
+
]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Handle help before parsing
|
|
50
|
+
if ARGV.empty? || ARGV.include?('help') || ARGV.include?('--help') || ARGV.include?('-h')
|
|
51
|
+
puts 'Usage: whctl [global flags] <command> [arguments]'
|
|
52
|
+
puts ''
|
|
53
|
+
puts 'Global flags:'
|
|
54
|
+
puts ' --context CONTEXT Override active context for this command'
|
|
55
|
+
puts ' --whconfig PATH Use alternate config file (default: ~/.walheim/config)'
|
|
56
|
+
puts ' -d, --data-dir DIR Data directory containing namespaces (deprecated: use contexts)'
|
|
57
|
+
puts ''
|
|
58
|
+
puts 'Available commands:'
|
|
59
|
+
puts ''
|
|
60
|
+
|
|
61
|
+
# Global commands
|
|
62
|
+
generate_global_help.each { |line| puts line }
|
|
63
|
+
puts ''
|
|
64
|
+
|
|
65
|
+
# Resource commands (dynamically generated)
|
|
66
|
+
Walheim::HandlerRegistry.all_visible.each do |handler_info|
|
|
67
|
+
kind_name = handler_info[:name]
|
|
68
|
+
kind_info = handler_info[:info]
|
|
69
|
+
|
|
70
|
+
# Show supported variations
|
|
71
|
+
variations = [kind_info[:singular]]
|
|
72
|
+
variations += kind_info[:aliases] if kind_info[:aliases]
|
|
73
|
+
variations_text = variations.empty? ? "" : " (aliases: #{variations.join(', ')})"
|
|
74
|
+
|
|
75
|
+
puts "#{kind_name.capitalize}#{variations_text}:"
|
|
76
|
+
generate_resource_help(handler_info).each { |line| puts line }
|
|
77
|
+
puts ''
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
exit 0
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Parse global flags first
|
|
84
|
+
options = {}
|
|
85
|
+
global_parser = OptionParser.new do |opts|
|
|
86
|
+
opts.on('--context CONTEXT', 'Override active context') { |v| options[:context] = v }
|
|
87
|
+
opts.on('--whconfig PATH', 'Alternate config file path') { |v| options[:whconfig] = v }
|
|
88
|
+
opts.on('-d DIR', '--data-dir DIR', 'Data directory (deprecated)') { |v| options[:data_dir] = v }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Parse only global flags, leaving the rest for later
|
|
92
|
+
begin
|
|
93
|
+
global_parser.order!(ARGV)
|
|
94
|
+
rescue OptionParser::InvalidOption => e
|
|
95
|
+
warn "Error: #{e.message}"
|
|
96
|
+
exit 1
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Now get the command (first remaining arg)
|
|
100
|
+
command = ARGV[0]
|
|
101
|
+
|
|
102
|
+
# Parse command-specific flags
|
|
103
|
+
command_parser = OptionParser.new do |opts|
|
|
104
|
+
opts.on('-n NAMESPACE', '--namespace NAMESPACE', 'Specify namespace') { |v| options[:namespace] = v }
|
|
105
|
+
opts.on('-A', '--all', 'All namespaces') { options[:all_namespaces] = true }
|
|
106
|
+
opts.on('-f FILE', '--file FILE', 'Manifest file path') { |v| options[:file] = v }
|
|
107
|
+
opts.on('-d DIR', '--data-dir DIR', 'Data directory containing namespaces') { |v| options[:data_dir] = v }
|
|
108
|
+
opts.on('--hostname HOSTNAME', 'Hostname for namespace') { |v| options[:hostname] = v }
|
|
109
|
+
opts.on('--username USERNAME', 'Username for namespace') { |v| options[:username] = v }
|
|
110
|
+
|
|
111
|
+
# Logs-specific options
|
|
112
|
+
opts.on('--follow', 'Follow log output') { options[:follow] = true }
|
|
113
|
+
opts.on('--tail N', Integer, 'Number of lines to show from end of logs') { |v| options[:tail] = v }
|
|
114
|
+
opts.on('--timestamps', 'Show timestamps') { options[:timestamps] = true }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Parse command flags
|
|
118
|
+
begin
|
|
119
|
+
command_parser.parse!(ARGV)
|
|
120
|
+
rescue OptionParser::InvalidOption => e
|
|
121
|
+
warn "Error: #{e.message}"
|
|
122
|
+
exit 1
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Get positional arguments after all flags are removed
|
|
126
|
+
kind = ARGV[1]
|
|
127
|
+
name = ARGV[2]
|
|
128
|
+
|
|
129
|
+
namespace = options[:namespace]
|
|
130
|
+
all_namespaces = options[:all_namespaces]
|
|
131
|
+
|
|
132
|
+
# Resolve data directory from context or --data-dir flag
|
|
133
|
+
data_dir = nil
|
|
134
|
+
|
|
135
|
+
# Skip context resolution for context commands (they manage config themselves)
|
|
136
|
+
unless command == 'context'
|
|
137
|
+
# Try to load config and resolve data directory from context
|
|
138
|
+
begin
|
|
139
|
+
config = Walheim::Config.new(config_path: options[:whconfig])
|
|
140
|
+
|
|
141
|
+
# Determine which context to use
|
|
142
|
+
context_name = options[:context] || config.current_context
|
|
143
|
+
|
|
144
|
+
if context_name
|
|
145
|
+
# Get data directory from the specified or active context
|
|
146
|
+
data_dir = config.data_dir(context_name)
|
|
147
|
+
elsif options[:data_dir]
|
|
148
|
+
# Fall back to --data-dir if provided (deprecated path)
|
|
149
|
+
data_dir = options[:data_dir]
|
|
150
|
+
warn "Warning: --data-dir is deprecated. Use 'whctl context new' to create a context."
|
|
151
|
+
else
|
|
152
|
+
# No context and no --data-dir
|
|
153
|
+
warn 'Error: No Walheim configuration found.'
|
|
154
|
+
warn ''
|
|
155
|
+
warn 'Create your first context:'
|
|
156
|
+
warn ' whctl context new <name> --data-dir <path>'
|
|
157
|
+
warn ''
|
|
158
|
+
warn 'Example:'
|
|
159
|
+
warn ' whctl context new homelab --data-dir ~/my-homelab'
|
|
160
|
+
exit 1
|
|
161
|
+
end
|
|
162
|
+
rescue Walheim::Config::ConfigError, Walheim::Config::ValidationError => e
|
|
163
|
+
# Config file doesn't exist or is invalid
|
|
164
|
+
if options[:data_dir]
|
|
165
|
+
# Fall back to --data-dir if provided
|
|
166
|
+
data_dir = options[:data_dir]
|
|
167
|
+
warn "Warning: --data-dir is deprecated. Use 'whctl context new' to create a context."
|
|
168
|
+
else
|
|
169
|
+
warn 'Error: No Walheim configuration found.'
|
|
170
|
+
warn ''
|
|
171
|
+
warn 'Create your first context:'
|
|
172
|
+
warn ' whctl context new <name> --data-dir <path>'
|
|
173
|
+
exit 1
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Default to current directory if still not set (shouldn't happen but safety)
|
|
179
|
+
data_dir ||= Dir.pwd
|
|
180
|
+
|
|
181
|
+
# For handlers that need it - namespaced resources use namespaces_dir
|
|
182
|
+
# Cluster resources use data_dir directly
|
|
183
|
+
namespaces_dir = File.join(data_dir, 'namespaces')
|
|
184
|
+
|
|
185
|
+
# Helper method to read YAML from file or stdin
|
|
186
|
+
def read_yaml_input(file_path)
|
|
187
|
+
if file_path == '-'
|
|
188
|
+
# Read from stdin
|
|
189
|
+
YAML.load(STDIN.read)
|
|
190
|
+
else
|
|
191
|
+
# Read from file
|
|
192
|
+
YAML.load_file(file_path)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Helper method to print resources table
|
|
197
|
+
def print_resources_table(result, all_namespaces, kind_name)
|
|
198
|
+
require 'terminal-table'
|
|
199
|
+
|
|
200
|
+
if result.is_a?(Hash)
|
|
201
|
+
# Single resource - print YAML
|
|
202
|
+
puts YAML.dump(result[:manifest])
|
|
203
|
+
return
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
if result.empty?
|
|
207
|
+
puts all_namespaces ? "No #{kind_name} found" : "No #{kind_name} found in namespace"
|
|
208
|
+
return
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Extract summary field names from first result
|
|
212
|
+
summary_fields = result.first[:summary].keys
|
|
213
|
+
|
|
214
|
+
# Build header row
|
|
215
|
+
if all_namespaces
|
|
216
|
+
headers = ['NAMESPACE', 'NAME'] + summary_fields.map(&:to_s).map(&:upcase)
|
|
217
|
+
else
|
|
218
|
+
headers = ['NAME'] + summary_fields.map(&:to_s).map(&:upcase)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Build data rows
|
|
222
|
+
rows = result.map do |resource|
|
|
223
|
+
row = if all_namespaces
|
|
224
|
+
[resource[:namespace], resource[:name]]
|
|
225
|
+
else
|
|
226
|
+
[resource[:name]]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Add summary field values
|
|
230
|
+
summary_values = summary_fields.map { |field| resource[:summary][field] || 'N/A' }
|
|
231
|
+
row + summary_values
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
all_rows = [headers] + rows
|
|
235
|
+
|
|
236
|
+
table = Terminal::Table.new do |t|
|
|
237
|
+
t.rows = all_rows
|
|
238
|
+
t.style = {
|
|
239
|
+
border_x: '', border_y: '', border_i: '',
|
|
240
|
+
padding_left: 0, padding_right: 3,
|
|
241
|
+
border_top: false, border_bottom: false,
|
|
242
|
+
all_separators: false
|
|
243
|
+
}
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
puts table
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def print_cluster_resources_table(result, kind_name)
|
|
250
|
+
require 'terminal-table'
|
|
251
|
+
|
|
252
|
+
if result.is_a?(Hash)
|
|
253
|
+
# Single resource - print YAML
|
|
254
|
+
puts YAML.dump(result[:manifest])
|
|
255
|
+
return
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if result.empty?
|
|
259
|
+
puts "No #{kind_name} found"
|
|
260
|
+
return
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Extract summary field names from first result
|
|
264
|
+
summary_fields = result.first[:summary].keys
|
|
265
|
+
|
|
266
|
+
# Build header row (no NAMESPACE column for cluster resources)
|
|
267
|
+
headers = ['NAME'] + summary_fields.map(&:to_s).map(&:upcase)
|
|
268
|
+
|
|
269
|
+
# Build data rows
|
|
270
|
+
rows = result.map do |resource|
|
|
271
|
+
row = [resource[:name]]
|
|
272
|
+
|
|
273
|
+
# Add summary field values
|
|
274
|
+
summary_values = summary_fields.map { |field| resource[:summary][field] || 'N/A' }
|
|
275
|
+
row + summary_values
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
all_rows = [headers] + rows
|
|
279
|
+
|
|
280
|
+
table = Terminal::Table.new do |t|
|
|
281
|
+
t.rows = all_rows
|
|
282
|
+
t.style = {
|
|
283
|
+
border_x: '', border_y: '', border_i: '',
|
|
284
|
+
padding_left: 0, padding_right: 3,
|
|
285
|
+
border_top: false, border_bottom: false,
|
|
286
|
+
all_separators: false
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
puts table
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Route commands
|
|
294
|
+
case command
|
|
295
|
+
when 'get'
|
|
296
|
+
handler_info = Walheim::HandlerRegistry.get(kind)
|
|
297
|
+
|
|
298
|
+
unless handler_info
|
|
299
|
+
warn "Error: unknown kind '#{kind}'"
|
|
300
|
+
warn ''
|
|
301
|
+
warn 'Available kinds:'
|
|
302
|
+
Walheim::HandlerRegistry.all_visible.each do |h|
|
|
303
|
+
warn " #{h[:name]}"
|
|
304
|
+
end
|
|
305
|
+
exit 1
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
handler = handler_info[:handler].new(data_dir: data_dir)
|
|
309
|
+
|
|
310
|
+
# Special handling for cluster resources (no namespace argument)
|
|
311
|
+
if handler.is_a?(Walheim::ClusterResource)
|
|
312
|
+
result = handler.get
|
|
313
|
+
print_cluster_resources_table(result, handler_info[:name])
|
|
314
|
+
else
|
|
315
|
+
# All other resources support namespace filtering
|
|
316
|
+
unless all_namespaces || namespace
|
|
317
|
+
warn 'Error: either -n {namespace} or --all/-A flag is required'
|
|
318
|
+
warn "Usage: whctl get #{handler_info[:name]} -n {namespace}"
|
|
319
|
+
warn "Usage: whctl get #{handler_info[:name]} --all"
|
|
320
|
+
exit 1
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Call handler and get data
|
|
324
|
+
result = if all_namespaces
|
|
325
|
+
handler.get(namespace: nil, name: nil)
|
|
326
|
+
else
|
|
327
|
+
handler.get(namespace: namespace, name: name)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Format and print output
|
|
331
|
+
print_resources_table(result, all_namespaces, handler_info[:name])
|
|
332
|
+
end
|
|
333
|
+
when 'apply'
|
|
334
|
+
# If -f is provided, extract namespace and name from manifest
|
|
335
|
+
if options[:file]
|
|
336
|
+
manifest_data = read_yaml_input(options[:file])
|
|
337
|
+
kind = manifest_data['kind']&.downcase + 's' # App -> apps
|
|
338
|
+
namespace = manifest_data['metadata']['namespace']
|
|
339
|
+
name = manifest_data['metadata']['name']
|
|
340
|
+
|
|
341
|
+
unless kind && namespace && name
|
|
342
|
+
warn 'Error: Manifest must contain kind, metadata.namespace, and metadata.name'
|
|
343
|
+
exit 1
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
handler_info = Walheim::HandlerRegistry.get(kind)
|
|
348
|
+
|
|
349
|
+
unless handler_info
|
|
350
|
+
warn "Error: unknown kind '#{kind}'"
|
|
351
|
+
exit 1
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Check if this resource supports apply operation
|
|
355
|
+
unless Walheim::HandlerRegistry.supports_operation?(kind, :apply)
|
|
356
|
+
warn "Error: apply not supported for kind '#{kind}'"
|
|
357
|
+
exit 1
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Validate required arguments
|
|
361
|
+
unless namespace && name
|
|
362
|
+
warn 'Error: -n and name are required (or use -f)'
|
|
363
|
+
warn "Usage: whctl apply #{handler_info[:name]} {name} -n {namespace}"
|
|
364
|
+
warn "Or: whctl apply -f {manifest.yaml}"
|
|
365
|
+
exit 1
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Execute operation
|
|
369
|
+
handler = handler_info[:handler].new(data_dir: data_dir)
|
|
370
|
+
handler.apply(namespace: namespace, name: name, manifest_source: options[:file])
|
|
371
|
+
when 'delete'
|
|
372
|
+
handler_info = Walheim::HandlerRegistry.get(kind)
|
|
373
|
+
|
|
374
|
+
unless handler_info
|
|
375
|
+
warn "Error: unknown kind '#{kind}'"
|
|
376
|
+
exit 1
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Check if this resource supports delete operation
|
|
380
|
+
unless Walheim::HandlerRegistry.supports_operation?(kind, :delete)
|
|
381
|
+
warn "Error: delete not supported for kind '#{kind}'"
|
|
382
|
+
exit 1
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Validate required arguments
|
|
386
|
+
unless namespace && name
|
|
387
|
+
warn 'Error: -n and name are required'
|
|
388
|
+
warn "Usage: whctl delete #{handler_info[:name]} {name} -n {namespace}"
|
|
389
|
+
exit 1
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Execute operation
|
|
393
|
+
handler = handler_info[:handler].new(data_dir: data_dir)
|
|
394
|
+
handler.delete(namespace: namespace, name: name)
|
|
395
|
+
when 'create'
|
|
396
|
+
# Only namespaces support create
|
|
397
|
+
if %w[namespace namespaces ns].include?(kind)
|
|
398
|
+
unless name
|
|
399
|
+
warn 'Error: namespace name is required'
|
|
400
|
+
warn 'Usage: whctl create namespace {name} [--username {user}] [--hostname {host}]'
|
|
401
|
+
exit 1
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Use namespace name as fallback for hostname
|
|
405
|
+
hostname = options[:hostname] || name
|
|
406
|
+
|
|
407
|
+
# Create namespace directory
|
|
408
|
+
namespace_path = File.join(namespaces_dir, name)
|
|
409
|
+
if Dir.exist?(namespace_path)
|
|
410
|
+
warn "Error: namespace '#{name}' already exists at #{namespace_path}"
|
|
411
|
+
exit 1
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Create directory structure
|
|
415
|
+
Dir.mkdir(namespace_path)
|
|
416
|
+
Dir.mkdir(File.join(namespace_path, 'apps'))
|
|
417
|
+
Dir.mkdir(File.join(namespace_path, 'secrets'))
|
|
418
|
+
Dir.mkdir(File.join(namespace_path, 'configmaps'))
|
|
419
|
+
|
|
420
|
+
# Create .namespace.yaml with optional username
|
|
421
|
+
config_path = File.join(namespace_path, '.namespace.yaml')
|
|
422
|
+
config_content = "hostname: #{hostname}\n"
|
|
423
|
+
config_content = "username: #{options[:username]}\n#{config_content}" if options[:username]
|
|
424
|
+
File.write(config_path, config_content)
|
|
425
|
+
|
|
426
|
+
puts "Created namespace '#{name}' at #{namespace_path}"
|
|
427
|
+
puts " Username: #{options[:username] || '(from SSH config)'}"
|
|
428
|
+
puts " Hostname: #{hostname}"
|
|
429
|
+
else
|
|
430
|
+
warn "Error: create not supported for kind '#{kind}'"
|
|
431
|
+
warn 'Usage: whctl create namespace {name} [--username {user}] [--hostname {host}]'
|
|
432
|
+
exit 1
|
|
433
|
+
end
|
|
434
|
+
when 'start', 'pause', 'stop'
|
|
435
|
+
# Resource-specific operations
|
|
436
|
+
handler_info = Walheim::HandlerRegistry.get(kind)
|
|
437
|
+
|
|
438
|
+
unless handler_info
|
|
439
|
+
warn "Error: unknown kind '#{kind}'"
|
|
440
|
+
exit 1
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Check if this resource supports the operation
|
|
444
|
+
unless Walheim::HandlerRegistry.supports_operation?(kind, command.to_sym)
|
|
445
|
+
warn "Error: #{command} not supported for #{kind}"
|
|
446
|
+
exit 1
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Validate required arguments
|
|
450
|
+
unless namespace && name
|
|
451
|
+
warn 'Error: -n and name are required'
|
|
452
|
+
warn "Usage: whctl #{command} #{handler_info[:name]} {name} -n {namespace}"
|
|
453
|
+
exit 1
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Execute operation
|
|
457
|
+
handler = handler_info[:handler].new(data_dir: data_dir)
|
|
458
|
+
handler.send(command.to_sym, namespace: namespace, name: name)
|
|
459
|
+
when 'logs'
|
|
460
|
+
# Resource-specific operation
|
|
461
|
+
handler_info = Walheim::HandlerRegistry.get(kind)
|
|
462
|
+
|
|
463
|
+
unless handler_info
|
|
464
|
+
warn "Error: unknown kind '#{kind}'"
|
|
465
|
+
exit 1
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Check if this resource supports logs operation
|
|
469
|
+
unless Walheim::HandlerRegistry.supports_operation?(kind, :logs)
|
|
470
|
+
warn "Error: logs not supported for #{kind}"
|
|
471
|
+
exit 1
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Validate required arguments
|
|
475
|
+
unless namespace && name
|
|
476
|
+
warn 'Error: -n and name are required'
|
|
477
|
+
warn "Usage: whctl logs #{handler_info[:name]} {name} -n {namespace} [--follow] [--tail N] [--timestamps]"
|
|
478
|
+
exit 1
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Build log options
|
|
482
|
+
log_options = {}
|
|
483
|
+
log_options[:follow] = true if options[:follow]
|
|
484
|
+
log_options[:tail] = options[:tail] if options[:tail]
|
|
485
|
+
log_options[:timestamps] = true if options[:timestamps]
|
|
486
|
+
|
|
487
|
+
# Execute operation
|
|
488
|
+
handler = handler_info[:handler].new(data_dir: data_dir)
|
|
489
|
+
handler.logs(namespace: namespace, name: name, **log_options)
|
|
490
|
+
when 'import'
|
|
491
|
+
# Only apps support import
|
|
492
|
+
unless %w[apps app].include?(kind)
|
|
493
|
+
warn "Error: import only supported for apps"
|
|
494
|
+
warn 'Usage: whctl import app {name} -n {namespace} -f {docker-compose.yml}'
|
|
495
|
+
exit 1
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# Require -f flag
|
|
499
|
+
unless options[:file]
|
|
500
|
+
warn 'Error: -f flag is required for import'
|
|
501
|
+
warn 'Usage: whctl import app {name} -n {namespace} -f {docker-compose.yml}'
|
|
502
|
+
warn 'Or: whctl import app {name} -n {namespace} -f - (read from stdin)'
|
|
503
|
+
exit 1
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Validate required arguments
|
|
507
|
+
unless namespace && name
|
|
508
|
+
warn 'Error: -n and name are required'
|
|
509
|
+
warn 'Usage: whctl import app {name} -n {namespace} -f {docker-compose.yml}'
|
|
510
|
+
exit 1
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Read docker-compose manifest
|
|
514
|
+
compose_manifest = read_yaml_input(options[:file])
|
|
515
|
+
|
|
516
|
+
# Execute import
|
|
517
|
+
handler_info = Walheim::HandlerRegistry.get('apps')
|
|
518
|
+
handler = handler_info[:handler].new(data_dir: data_dir)
|
|
519
|
+
handler.import(namespace: namespace, name: name, compose_manifest: compose_manifest)
|
|
520
|
+
when 'context'
|
|
521
|
+
# Context management commands
|
|
522
|
+
subcommand = kind # The kind position is the subcommand for context
|
|
523
|
+
context_name = name # The name position is the context name
|
|
524
|
+
|
|
525
|
+
case subcommand
|
|
526
|
+
when 'new'
|
|
527
|
+
# whctl context new {name} --data-dir {path}
|
|
528
|
+
unless context_name
|
|
529
|
+
warn 'Error: context name is required'
|
|
530
|
+
warn 'Usage: whctl context new {name} --data-dir {path}'
|
|
531
|
+
exit 1
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
unless options[:data_dir]
|
|
535
|
+
warn 'Error: --data-dir flag is required'
|
|
536
|
+
warn 'Usage: whctl context new {name} --data-dir {path}'
|
|
537
|
+
exit 1
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Validate data_dir exists or can be created
|
|
541
|
+
expanded_data_dir = File.expand_path(options[:data_dir])
|
|
542
|
+
unless Dir.exist?(expanded_data_dir)
|
|
543
|
+
warn "Error: data directory '#{expanded_data_dir}' does not exist"
|
|
544
|
+
warn ''
|
|
545
|
+
warn 'Please create the directory first or provide a valid path'
|
|
546
|
+
exit 1
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Check for namespaces subdirectory
|
|
550
|
+
namespaces_path = File.join(expanded_data_dir, 'namespaces')
|
|
551
|
+
unless Dir.exist?(namespaces_path)
|
|
552
|
+
warn "Warning: data directory does not contain 'namespaces' subdirectory"
|
|
553
|
+
warn "Expected path: #{namespaces_path}"
|
|
554
|
+
warn ''
|
|
555
|
+
warn 'This directory should contain your homelab configuration.'
|
|
556
|
+
warn 'Creating namespaces directory...'
|
|
557
|
+
Dir.mkdir(namespaces_path)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Load or create config
|
|
561
|
+
config = Walheim::Config.new(config_path: options[:whconfig])
|
|
562
|
+
|
|
563
|
+
begin
|
|
564
|
+
config.add_context(context_name, expanded_data_dir, activate: true)
|
|
565
|
+
config.save_config
|
|
566
|
+
puts "Created context '#{context_name}'"
|
|
567
|
+
puts " Data directory: #{expanded_data_dir}"
|
|
568
|
+
puts " Status: Active (automatically activated)"
|
|
569
|
+
rescue Walheim::Config::ValidationError => e
|
|
570
|
+
warn "Error: #{e.message}"
|
|
571
|
+
exit 1
|
|
572
|
+
rescue Walheim::Config::ConfigError => e
|
|
573
|
+
warn "Error: #{e.message}"
|
|
574
|
+
exit 1
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
when 'list'
|
|
578
|
+
# whctl context list
|
|
579
|
+
config = Walheim::Config.new(config_path: options[:whconfig])
|
|
580
|
+
|
|
581
|
+
unless Walheim::Config.exists?(config_path: options[:whconfig])
|
|
582
|
+
warn 'No Walheim configuration found.'
|
|
583
|
+
warn ''
|
|
584
|
+
warn 'Create your first context:'
|
|
585
|
+
warn ' whctl context new <name> --data-dir <path>'
|
|
586
|
+
exit 1
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
contexts = config.list_contexts
|
|
590
|
+
if contexts.empty?
|
|
591
|
+
puts 'No contexts configured.'
|
|
592
|
+
exit 0
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
require 'terminal-table'
|
|
596
|
+
|
|
597
|
+
rows = contexts.map do |ctx|
|
|
598
|
+
active_marker = ctx['active'] ? '*' : ''
|
|
599
|
+
[active_marker, ctx['name'], ctx['dataDir']]
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
table = Terminal::Table.new do |t|
|
|
603
|
+
t.headings = ['CURRENT', 'NAME', 'DATA DIRECTORY']
|
|
604
|
+
t.rows = rows
|
|
605
|
+
t.style = {
|
|
606
|
+
border_x: '', border_y: '', border_i: '',
|
|
607
|
+
padding_left: 0, padding_right: 3,
|
|
608
|
+
border_top: false, border_bottom: false,
|
|
609
|
+
all_separators: false
|
|
610
|
+
}
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
puts table
|
|
614
|
+
|
|
615
|
+
when 'use'
|
|
616
|
+
# whctl context use {name}
|
|
617
|
+
unless context_name
|
|
618
|
+
warn 'Error: context name is required'
|
|
619
|
+
warn 'Usage: whctl context use {name}'
|
|
620
|
+
exit 1
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
config = Walheim::Config.new(config_path: options[:whconfig])
|
|
624
|
+
|
|
625
|
+
begin
|
|
626
|
+
config.use_context(context_name)
|
|
627
|
+
config.save_config
|
|
628
|
+
puts "Switched to context '#{context_name}'"
|
|
629
|
+
rescue Walheim::Config::ConfigError => e
|
|
630
|
+
warn "Error: #{e.message}"
|
|
631
|
+
exit 1
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
when 'current'
|
|
635
|
+
# whctl context current
|
|
636
|
+
config = Walheim::Config.new(config_path: options[:whconfig])
|
|
637
|
+
|
|
638
|
+
unless Walheim::Config.exists?(config_path: options[:whconfig])
|
|
639
|
+
warn 'No Walheim configuration found.'
|
|
640
|
+
warn ''
|
|
641
|
+
warn 'Create your first context:'
|
|
642
|
+
warn ' whctl context new <name> --data-dir <path>'
|
|
643
|
+
exit 1
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
if config.current_context
|
|
647
|
+
puts "Current context: #{config.current_context}"
|
|
648
|
+
puts "Data directory: #{config.data_dir}"
|
|
649
|
+
else
|
|
650
|
+
puts 'No active context selected.'
|
|
651
|
+
puts ''
|
|
652
|
+
puts 'Available contexts:'
|
|
653
|
+
config.list_contexts.each do |ctx|
|
|
654
|
+
puts " - #{ctx['name']}"
|
|
655
|
+
end
|
|
656
|
+
puts ''
|
|
657
|
+
puts 'Select a context:'
|
|
658
|
+
puts ' whctl context use <context-name>'
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
when 'delete'
|
|
662
|
+
# whctl context delete {name}
|
|
663
|
+
unless context_name
|
|
664
|
+
warn 'Error: context name is required'
|
|
665
|
+
warn 'Usage: whctl context delete {name}'
|
|
666
|
+
exit 1
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
config = Walheim::Config.new(config_path: options[:whconfig])
|
|
670
|
+
|
|
671
|
+
begin
|
|
672
|
+
config.delete_context(context_name)
|
|
673
|
+
config.save_config
|
|
674
|
+
puts "Deleted context '#{context_name}'"
|
|
675
|
+
|
|
676
|
+
# Show warning if this was the active context
|
|
677
|
+
unless config.current_context
|
|
678
|
+
puts ''
|
|
679
|
+
puts 'Note: This was your active context.'
|
|
680
|
+
puts 'Select a new context with:'
|
|
681
|
+
puts ' whctl context use <context-name>'
|
|
682
|
+
end
|
|
683
|
+
rescue Walheim::Config::ConfigError => e
|
|
684
|
+
warn "Error: #{e.message}"
|
|
685
|
+
exit 1
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
else
|
|
689
|
+
warn "Error: unknown context subcommand '#{subcommand}'"
|
|
690
|
+
warn ''
|
|
691
|
+
warn 'Available context commands:'
|
|
692
|
+
warn ' whctl context new {name} --data-dir {path}'
|
|
693
|
+
warn ' whctl context list'
|
|
694
|
+
warn ' whctl context use {name}'
|
|
695
|
+
warn ' whctl context current'
|
|
696
|
+
warn ' whctl context delete {name}'
|
|
697
|
+
exit 1
|
|
698
|
+
end
|
|
699
|
+
else
|
|
700
|
+
warn "Error: unknown command '#{command}'"
|
|
701
|
+
warn 'Usage: whctl <command> [arguments]'
|
|
702
|
+
exit 1
|
|
703
|
+
end
|