walheim 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b943785b380c1a8fcd220c0f9c4716a6e9fe5d255b31e7b874115f0b0c8734a
4
- data.tar.gz: a5d62f283a0da1189b07129512a0f7698b3c4811f653f5735576139fa76c4f0f
3
+ metadata.gz: c859436cfcc9582a4a875cf547855472d592b37e391f58abadd0003943272d1d
4
+ data.tar.gz: 15b5ad58f1c94475882e31c41c203f6a8446a63629ed7543957fc3244282be2b
5
5
  SHA512:
6
- metadata.gz: 3b01e7611db7c3f589c3c0043ed8762f1fac6768c884f177c045a188ff16440b0f5ac4184b31ad681febdf2b79d7db247856c191fd9bb074cdfe4884bc4521aa
7
- data.tar.gz: 5374cfd930d9ddb54117209f2ac9eefa1436dbc293db46d2ef8f1307dd91b6bba72b82ce918ef8511569720d9992662fa59c0103e2bdf0bc0544db021a72f71d
6
+ metadata.gz: b4c2b0a0ff5d4e6ca42d2ac2159c85689fc7faca2f633061d11b8d3cb8a2cf3bf2d4e9d07c35ca6d2abfb9768fbdcaacd49a0ccb673ba0b0cab55da3ec80c5ba
7
+ data.tar.gz: 2c1c9369e7372cf6c21d1771870e805e43f10a8a082dc886d4a18e6f7ad70bd272e50bb3463c6cafeb8a37acf5a351b0d7efe5ba12b070c6ae45092d92e4d375
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Walheim
2
2
 
3
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/walheimlab/walheim-rb)
4
+
3
5
  A kubectl-style CLI for managing Docker-based homelab infrastructure across multiple machines.
4
6
 
5
7
  ## Overview
@@ -16,22 +16,30 @@ module Walheim
16
16
  end
17
17
 
18
18
  # 2. Check operation support
19
+ handler_class = handler_info[:handler]
19
20
  unless Walheim::HandlerRegistry.supports_operation?(kind, operation)
20
21
  warn "Error: #{operation} not supported for #{kind}"
21
22
  exit 1
22
23
  end
23
24
 
24
- # 3. Resolve data directory from context
25
+ # 3. Get operation metadata
26
+ op_metadata = handler_class.operation_info[operation]
27
+ unless op_metadata
28
+ warn "Error: operation metadata missing for #{operation}"
29
+ exit 1
30
+ end
31
+
32
+ # 4. Resolve data directory from context
25
33
  data_dir = resolve_data_dir(options, parent_options)
26
34
 
27
- # 4. Initialize handler
28
- handler = handler_info[:handler].new(data_dir: data_dir)
35
+ # 5. Initialize handler
36
+ handler = handler_class.new(data_dir: data_dir)
29
37
 
30
- # 5. Validate namespace requirements
31
- validate_namespace_options!(operation, kind, name, options) if handler.is_a?(Walheim::NamespacedResource)
38
+ # 6. Validate namespace requirements
39
+ validate_namespace_requirements!(operation, kind, name, options, op_metadata) if handler.is_a?(Walheim::NamespacedResource)
32
40
 
33
- # 6. Dispatch to handler
34
- dispatch_to_handler(handler, operation, name, options, handler_info)
41
+ # 7. Dispatch to handler using metadata
42
+ dispatch_operation(handler, operation, name, options, handler_info, op_metadata)
35
43
  end
36
44
 
37
45
  def self.resolve_data_dir(options, parent_options)
@@ -70,101 +78,97 @@ module Walheim
70
78
  end
71
79
  end
72
80
 
73
- def self.validate_namespace_options!(operation, kind, _name, options)
74
- # Operations that require namespace or --all
75
- requires_namespace = %i[get apply delete start pause stop logs import]
76
- return unless requires_namespace.include?(operation)
81
+ def self.validate_namespace_requirements!(operation, kind, _name, options, op_metadata)
82
+ dispatch_meta = op_metadata[:dispatch] || {}
83
+ namespace_handling = dispatch_meta[:namespace_handling]
77
84
 
78
- # get can use --all
79
- if operation == :get
85
+ case namespace_handling
86
+ when :optional_with_all
87
+ # Operations like get can use --all or -n
80
88
  return if options[:all] || options[:namespace]
81
89
 
82
90
  warn "Error: either -n {namespace} or --all/-A flag is required"
83
- warn "Usage: whctl get #{kind} -n {namespace}"
84
- warn "Usage: whctl get #{kind} --all"
91
+ warn "Usage: whctl #{operation} #{kind} -n {namespace}"
92
+ warn "Usage: whctl #{operation} #{kind} --all"
85
93
  exit 1
86
- end
87
-
88
- # Other operations require namespace
89
- return if options[:namespace]
94
+ when :required
95
+ # Operations require namespace
96
+ return if options[:namespace]
90
97
 
91
- warn "Error: -n {namespace} is required"
92
- warn "Usage: whctl #{operation} #{kind} {name} -n {namespace}"
93
- exit 1
94
- end
95
-
96
- def self.dispatch_to_handler(handler, operation, name, options, handler_info)
97
- case operation
98
- when :get
99
- dispatch_get(handler, name, options, handler_info)
100
- when :apply
101
- dispatch_apply(handler, name, options, handler_info)
102
- when :delete
103
- handler.delete(namespace: options[:namespace], name: name)
104
- when :create
105
- # Special case: create namespace
106
- handler.create(name: name, username: options[:username], hostname: options[:hostname])
107
- when :import
108
- # Special case: import app
109
- compose_manifest = Walheim::Helpers.read_yaml_input(options[:file])
110
- handler.import(namespace: options[:namespace], name: name, compose_manifest: compose_manifest)
111
- when :start, :pause, :stop
112
- handler.send(operation, namespace: options[:namespace], name: name)
113
- when :logs
114
- log_opts = {}
115
- log_opts[:follow] = options[:follow] if options[:follow]
116
- log_opts[:tail] = options[:tail] if options[:tail]
117
- log_opts[:timestamps] = options[:timestamps] if options[:timestamps]
118
- handler.logs(namespace: options[:namespace], name: name, **log_opts)
98
+ warn "Error: -n {namespace} is required"
99
+ warn "Usage: whctl #{operation} #{kind} {name} -n {namespace}"
100
+ exit 1
101
+ when nil
102
+ # No namespace validation needed
103
+ nil
119
104
  else
120
- warn "Error: operation #{operation} not implemented"
105
+ warn "Error: unknown namespace_handling: #{namespace_handling}"
121
106
  exit 1
122
107
  end
123
108
  end
124
109
 
125
- def self.dispatch_get(handler, name, options, handler_info)
126
- if handler.is_a?(Walheim::ClusterResource)
127
- result = handler.get(name: name)
128
- Walheim::Helpers.print_cluster_resources_table(result, handler_info[:name])
129
- else
130
- result = if options[:all]
131
- handler.get(namespace: nil, name: nil)
110
+ def self.dispatch_operation(handler, operation, name, options, handler_info, op_metadata)
111
+ dispatch_meta = op_metadata[:dispatch] || {}
112
+ method_name = dispatch_meta[:method] || operation
113
+
114
+ # Build method parameters
115
+ params = build_method_params(name, options, dispatch_meta)
116
+
117
+ # Call handler method
118
+ result = handler.send(method_name, **params)
119
+
120
+ # Handle output formatting
121
+ handle_output(result, handler, handler_info, options, dispatch_meta)
122
+ end
123
+
124
+ def self.build_method_params(name, options, dispatch_meta)
125
+ params = {}
126
+
127
+ # Add positional params (like :name)
128
+ positional_params = dispatch_meta[:params] || []
129
+ positional_params.each do |param_name|
130
+ case param_name
131
+ when :name
132
+ params[:name] = name
132
133
  else
133
- handler.get(namespace: options[:namespace], name: name)
134
+ params[param_name] = options[param_name]
134
135
  end
135
- Walheim::Helpers.print_resources_table(result, options[:all], handler_info[:name])
136
136
  end
137
- end
138
137
 
139
- def self.dispatch_apply(handler, name, options, _handler_info)
140
- # Extract from manifest if -f provided
141
- if options[:file]
142
- manifest_data = Walheim::Helpers.read_yaml_input(options[:file])
138
+ # Add named params from options
139
+ named_params = dispatch_meta[:named_params] || {}
140
+ named_params.each do |param_name, option_key|
141
+ option_value = options[option_key]
143
142
 
144
- if handler.is_a?(Walheim::NamespacedResource)
145
- namespace = manifest_data["metadata"]["namespace"]
146
- name = manifest_data["metadata"]["name"]
143
+ # Handle file readers
144
+ if dispatch_meta[:file_reader] == param_name && option_value
145
+ option_value = Walheim::Helpers.read_yaml_input(option_value)
146
+ end
147
147
 
148
- unless namespace && name
149
- warn "Error: Manifest must contain metadata.namespace and metadata.name"
150
- exit 1
151
- end
148
+ # Always include declared parameters (handlers expect them as keyword args)
149
+ params[param_name] = option_value
150
+ end
151
+
152
+ params
153
+ end
152
154
 
153
- handler.apply(namespace: namespace, name: name, manifest_source: options[:file])
155
+ def self.handle_output(result, handler, handler_info, options, dispatch_meta)
156
+ output_type = dispatch_meta[:output]
157
+
158
+ case output_type
159
+ when :table
160
+ # Table output for get operations
161
+ if handler.is_a?(Walheim::ClusterResource)
162
+ Walheim::Helpers.print_cluster_resources_table(result, handler_info[:name])
154
163
  else
155
- # Cluster resource
156
- name = manifest_data["metadata"]["name"]
157
- unless name
158
- warn "Error: Manifest must contain metadata.name"
159
- exit 1
160
- end
161
- handler.apply(name: name, manifest_source: options[:file])
164
+ Walheim::Helpers.print_resources_table(result, options[:all], handler_info[:name])
162
165
  end
163
- elsif handler.is_a?(Walheim::NamespacedResource)
164
- # Apply from existing manifest in data dir
165
- handler.apply(namespace: options[:namespace], name: name)
166
+ when nil
167
+ # No output handling needed (handler prints directly)
168
+ nil
166
169
  else
167
- handler.apply(name: name)
170
+ warn "Error: unknown output type: #{output_type}"
171
+ exit 1
168
172
  end
169
173
  end
170
174
  end
data/lib/walheim/cli.rb CHANGED
@@ -7,6 +7,11 @@ require_relative "cli/resource_command"
7
7
 
8
8
  module Walheim
9
9
  class CLI < Thor
10
+ # Exit with non-zero status on errors
11
+ def self.exit_on_failure?
12
+ true
13
+ end
14
+
10
15
  # Global flags
11
16
  class_option :context,
12
17
  type: :string,
@@ -34,6 +39,45 @@ module Walheim
34
39
  puts "whctl version #{Walheim::VERSION}"
35
40
  end
36
41
 
42
+ # Exec command - custom implementation to support variadic arguments
43
+ desc "exec app NAME [--] COMMAND...", "Execute command in app container"
44
+ method_option :namespace,
45
+ type: :string,
46
+ aliases: [ :n ],
47
+ desc: "Target namespace",
48
+ required: true
49
+ method_option :service,
50
+ type: :string,
51
+ aliases: [ :s ],
52
+ desc: "Target service (defaults to first)"
53
+ method_option :interactive,
54
+ type: :boolean,
55
+ aliases: [ :it ],
56
+ desc: "Allocate pseudo-TTY and keep stdin open",
57
+ default: false
58
+ def exec(kind, name, *command)
59
+ # Validate kind is 'app' or 'apps'
60
+ unless %w[app apps].include?(kind.downcase)
61
+ warn "Error: exec only supports 'app' kind, got '#{kind}'"
62
+ exit 1
63
+ end
64
+
65
+ # Resolve data directory from context
66
+ data_dir = BaseCommand.send(:resolve_data_dir, options, self.class.class_options.transform_keys(&:to_sym).transform_values { |v| options[v.name] rescue nil })
67
+
68
+ # Initialize Apps handler
69
+ handler = Resources::Apps.new(data_dir: data_dir)
70
+
71
+ # Call exec_command method
72
+ handler.exec_command(
73
+ namespace: options[:namespace],
74
+ name: name,
75
+ service: options[:service],
76
+ interactive: options[:interactive],
77
+ command: command
78
+ )
79
+ end
80
+
37
81
  # Override help to maintain kubectl-style help
38
82
  def self.help(shell, subcommand = false)
39
83
  list = printable_commands(true, subcommand)
@@ -7,7 +7,7 @@ module Walheim
7
7
  # Examples: Apps, Secrets, ConfigMaps
8
8
  # These resources live under namespaces/{namespace}/{kind}/{name}/
9
9
  class NamespacedResource < Resource
10
- # Override operation_info to add namespace flags
10
+ # Override operation_info to add namespace flags and dispatch params
11
11
  def self.operation_info
12
12
  ops = super
13
13
 
@@ -31,6 +31,17 @@ module Walheim
31
31
  ops[:apply][:options].merge!(namespace: namespace_options[:namespace])
32
32
  ops[:delete][:options].merge!(namespace: namespace_options[:namespace])
33
33
 
34
+ # Add namespace parameter to dispatch
35
+ ops[:get][:dispatch][:named_params] = { namespace: :namespace }
36
+ ops[:get][:dispatch][:namespace_handling] = :optional_with_all
37
+
38
+ ops[:apply][:dispatch][:named_params] ||= {}
39
+ ops[:apply][:dispatch][:named_params][:namespace] = :namespace
40
+ ops[:apply][:dispatch][:namespace_handling] = :required
41
+
42
+ ops[:delete][:dispatch][:named_params] = { namespace: :namespace }
43
+ ops[:delete][:dispatch][:namespace_handling] = :required
44
+
34
45
  ops
35
46
  end
36
47
 
@@ -35,7 +35,7 @@ module Walheim
35
35
  {} # Default: no summary fields
36
36
  end
37
37
 
38
- # Operation metadata - defines how operations appear in help
38
+ # Operation metadata - defines how operations appear in help and how they're dispatched
39
39
  # Subclasses can override to add custom operations
40
40
  def self.operation_info
41
41
  {
@@ -46,19 +46,35 @@ module Walheim
46
46
  "get #{kind_info[:plural]} --all/-A",
47
47
  "get #{kind_info[:singular]} {name} -n {namespace}"
48
48
  ],
49
- options: {} # Subclasses will override
49
+ options: {}, # Subclasses will override
50
+ dispatch: {
51
+ method: :get,
52
+ params: [ :name ],
53
+ output: :table
54
+ }
50
55
  },
51
56
  apply: {
52
57
  description: "Create or update a resource",
53
58
  usage: [ "apply #{kind_info[:singular]} {name} -n {namespace}" ],
54
59
  options: {
55
60
  file: { type: :string, aliases: [ :f ], desc: "Manifest file (use - for stdin)" }
61
+ },
62
+ dispatch: {
63
+ method: :apply,
64
+ params: [ :name ],
65
+ named_params: {
66
+ manifest_source: :file
67
+ }
56
68
  }
57
69
  },
58
70
  delete: {
59
71
  description: "Delete a resource",
60
72
  usage: [ "delete #{kind_info[:singular]} {name} -n {namespace}" ],
61
- options: {} # Subclasses will override
73
+ options: {}, # Subclasses will override
74
+ dispatch: {
75
+ method: :delete,
76
+ params: [ :name ]
77
+ }
62
78
  }
63
79
  }
64
80
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "shellwords"
4
+ require "parallel"
3
5
  require_relative "../namespaced_resource"
4
6
  require_relative "../sync"
5
7
  require_relative "../handler_registry"
@@ -35,8 +37,14 @@ module Resources
35
37
  # Extract first service's image from compose spec
36
38
  manifest.dig("spec", "compose", "services")&.values&.first&.dig("image") || "N/A"
37
39
  },
38
- status: lambda { |_manifest|
39
- # Could check if app is running, for now just show 'Configured'
40
+ ready: lambda { |manifest|
41
+ # Actual ready count is fetched and cached by get() method
42
+ # This lambda is a placeholder
43
+ "-"
44
+ },
45
+ status: lambda { |manifest|
46
+ # Actual status is fetched and cached by get() method
47
+ # This lambda reads from the cache
40
48
  "Configured"
41
49
  }
42
50
  }
@@ -46,7 +54,7 @@ module Resources
46
54
  # Start with base operations
47
55
  ops = super
48
56
 
49
- namespace_opt = { type: :string, aliases: [ :n ], desc: "Target namespace", required: true }
57
+ namespace_opt = { type: :string, aliases: [ :n ], desc: "Target namespace" }
50
58
 
51
59
  # Add apps-specific operations
52
60
  ops.merge({
@@ -56,22 +64,50 @@ module Resources
56
64
  options: {
57
65
  namespace: namespace_opt,
58
66
  file: { type: :string, aliases: [ :f ], desc: "docker-compose.yml path", required: true }
67
+ },
68
+ dispatch: {
69
+ method: :import,
70
+ params: [ :name ],
71
+ named_params: {
72
+ namespace: :namespace,
73
+ compose_manifest: :file
74
+ },
75
+ namespace_handling: :required,
76
+ file_reader: :compose_manifest # Read YAML from file option
59
77
  }
60
78
  },
61
79
  start: {
62
80
  description: "Compile, sync, and start app on host",
63
81
  usage: [ "start app {name} -n {namespace}" ],
64
- options: { namespace: namespace_opt }
82
+ options: { namespace: namespace_opt },
83
+ dispatch: {
84
+ method: :start,
85
+ params: [ :name ],
86
+ named_params: { namespace: :namespace },
87
+ namespace_handling: :required
88
+ }
65
89
  },
66
90
  pause: {
67
91
  description: "Stop app containers (keep files)",
68
92
  usage: [ "pause app {name} -n {namespace}" ],
69
- options: { namespace: namespace_opt }
93
+ options: { namespace: namespace_opt },
94
+ dispatch: {
95
+ method: :pause,
96
+ params: [ :name ],
97
+ named_params: { namespace: :namespace },
98
+ namespace_handling: :required
99
+ }
70
100
  },
71
101
  stop: {
72
102
  description: "Stop app and remove files from host",
73
103
  usage: [ "stop app {name} -n {namespace}" ],
74
- options: { namespace: namespace_opt }
104
+ options: { namespace: namespace_opt },
105
+ dispatch: {
106
+ method: :stop,
107
+ params: [ :name ],
108
+ named_params: { namespace: :namespace },
109
+ namespace_handling: :required
110
+ }
75
111
  },
76
112
  logs: {
77
113
  description: "View logs from remote containers",
@@ -86,6 +122,39 @@ module Resources
86
122
  follow: { type: :boolean, desc: "Follow log output" },
87
123
  tail: { type: :numeric, desc: "Number of lines from end" },
88
124
  timestamps: { type: :boolean, desc: "Show timestamps" }
125
+ },
126
+ dispatch: {
127
+ method: :logs,
128
+ params: [ :name ],
129
+ named_params: {
130
+ namespace: :namespace,
131
+ follow: :follow,
132
+ tail: :tail,
133
+ timestamps: :timestamps
134
+ },
135
+ namespace_handling: :required
136
+ }
137
+ },
138
+ pull: {
139
+ description: "Pull latest images without restarting",
140
+ usage: [ "pull app {name} -n {namespace}" ],
141
+ options: { namespace: namespace_opt },
142
+ dispatch: {
143
+ method: :pull,
144
+ params: [ :name ],
145
+ named_params: { namespace: :namespace },
146
+ namespace_handling: :required
147
+ }
148
+ },
149
+ describe: {
150
+ description: "Show running status of app containers",
151
+ usage: [ "describe app {name} -n {namespace}" ],
152
+ options: { namespace: namespace_opt },
153
+ dispatch: {
154
+ method: :describe,
155
+ params: [ :name ],
156
+ named_params: { namespace: :namespace },
157
+ namespace_handling: :required
89
158
  }
90
159
  }
91
160
  })
@@ -233,6 +302,307 @@ module Resources
233
302
  exec(ssh_command)
234
303
  end
235
304
 
305
+ def pull(namespace:, name:)
306
+ # Get namespace config
307
+ namespace_config = load_namespace_config(namespace)
308
+ username = namespace_config["username"]
309
+ hostname = namespace_config["hostname"]
310
+
311
+ remote_host = username ? "#{username}@#{hostname}" : hostname
312
+ remote_dir = "/data/walheim/apps/#{name}"
313
+
314
+ # Check if remote directory exists
315
+ check_command = "ssh #{remote_host} 'test -d #{remote_dir}'"
316
+ dir_exists = system(check_command)
317
+
318
+ unless dir_exists
319
+ warn "Error: app '#{name}' not found on #{remote_host}"
320
+ warn "Deploy the app first using 'whctl apply app #{name} -n #{namespace}'"
321
+ exit 1
322
+ end
323
+
324
+ # Pull latest images
325
+ puts "Pulling latest images for '#{name}' on #{remote_host}"
326
+ ssh_command = "ssh #{remote_host} 'cd #{remote_dir} && docker compose pull'"
327
+ result = system(ssh_command)
328
+
329
+ unless result
330
+ warn "Error: docker compose pull failed"
331
+ exit 1
332
+ end
333
+
334
+ puts "Successfully pulled latest images for app '#{name}' in namespace '#{namespace}'"
335
+ puts "Use 'whctl start app #{name} -n #{namespace}' to apply the pulled images"
336
+ end
337
+
338
+ def describe(namespace:, name:)
339
+ # Get namespace config
340
+ namespace_config = load_namespace_config(namespace)
341
+ username = namespace_config["username"]
342
+ hostname = namespace_config["hostname"]
343
+
344
+ remote_host = username ? "#{username}@#{hostname}" : hostname
345
+ remote_dir = "/data/walheim/apps/#{name}"
346
+
347
+ # Check if remote directory exists
348
+ check_command = "ssh #{remote_host} 'test -d #{remote_dir}'"
349
+ dir_exists = system(check_command)
350
+
351
+ unless dir_exists
352
+ warn "Error: app '#{name}' not found on #{remote_host}"
353
+ warn "Deploy the app first using 'whctl apply app #{name} -n #{namespace}'"
354
+ exit 1
355
+ end
356
+
357
+ # Display container status using docker compose ps
358
+ puts "Status of '#{name}' on #{remote_host}:\n\n"
359
+
360
+ # Get container status
361
+ ps_command = "ssh #{remote_host} 'cd #{remote_dir} && docker compose ps'"
362
+ system(ps_command)
363
+
364
+ puts "\n"
365
+
366
+ # Get resource usage for running containers
367
+ stats_command = "ssh #{remote_host} 'cd #{remote_dir} && docker compose ps -q | xargs -r docker stats --no-stream --format \"table {{.Name}}\\t{{.CPUPerc}}\\t{{.MemUsage}}\\t{{.NetIO}}\\t{{.BlockIO}}\"'"
368
+
369
+ puts "Resource Usage:"
370
+ stats_result = system(stats_command)
371
+
372
+ unless stats_result
373
+ puts "(No running containers or unable to fetch stats)"
374
+ end
375
+ end
376
+
377
+ def exec_command(namespace:, name:, service: nil, interactive: false, command:)
378
+ # Get namespace config
379
+ namespace_config = load_namespace_config(namespace)
380
+ username = namespace_config["username"]
381
+ hostname = namespace_config["hostname"]
382
+
383
+ remote_host = username ? "#{username}@#{hostname}" : hostname
384
+ remote_dir = "/data/walheim/apps/#{name}"
385
+
386
+ # Check if remote directory exists
387
+ check_command = "ssh #{remote_host} 'test -d #{remote_dir}'"
388
+ dir_exists = system(check_command)
389
+
390
+ unless dir_exists
391
+ warn "Error: app '#{name}' not found on #{remote_host}"
392
+ warn "Deploy the app first using 'whctl apply app #{name} -n #{namespace}'"
393
+ exit 1
394
+ end
395
+
396
+ # Validate command
397
+ if command.nil? || command.empty?
398
+ warn "Error: command is required"
399
+ warn "Usage: whctl exec app {name} -n {namespace} [--] {command} [args...]"
400
+ exit 1
401
+ end
402
+
403
+ # Determine service name
404
+ service_name = service
405
+ unless service_name
406
+ # Get first service from docker-compose.yml
407
+ app_manifest = load_app_manifest(namespace, name)
408
+ services = app_manifest[:compose]["services"]
409
+
410
+ if services.nil? || services.empty?
411
+ warn "Error: no services found in app '#{name}'"
412
+ exit 1
413
+ end
414
+
415
+ service_name = services.keys.first
416
+ puts "Defaulting to service: #{service_name}"
417
+ end
418
+
419
+ # Build docker compose exec command
420
+ # command is an array, so join with spaces and properly escape
421
+ command_str = Shellwords.join(command)
422
+ exec_cmd = "docker compose exec"
423
+ exec_cmd += " -T" unless interactive # Disable pseudo-TTY allocation when not interactive
424
+ exec_cmd += " -it" if interactive
425
+ exec_cmd += " #{Shellwords.escape(service_name)} #{command_str}"
426
+
427
+ # Build SSH command
428
+ # Use -t flag for SSH when interactive mode is requested
429
+ ssh_flags = interactive ? "-t" : ""
430
+ ssh_command = "ssh #{ssh_flags} #{remote_host} 'cd #{remote_dir} && #{exec_cmd}'"
431
+
432
+ # Use exec to replace current process for proper signal handling
433
+ Kernel.exec(ssh_command)
434
+ end
435
+
436
+ # Override get to pre-fetch container status
437
+ def get(namespace:, name: nil)
438
+ # Determine which namespaces to fetch status for
439
+ namespaces_to_fetch = if namespace.nil?
440
+ # Fetching all namespaces
441
+ all_namespace_names
442
+ elsif name.nil?
443
+ # Fetching single namespace
444
+ [ namespace ]
445
+ else
446
+ # Fetching single resource
447
+ [ namespace ]
448
+ end
449
+
450
+ # Pre-fetch container status for relevant namespaces
451
+ fetch_container_status_batch(namespaces_to_fetch)
452
+
453
+ # Call parent get implementation
454
+ super
455
+ end
456
+
457
+ private
458
+
459
+ def all_namespace_names
460
+ namespaces_dir = File.join(@data_dir, "namespaces")
461
+ return [] unless Dir.exist?(namespaces_dir)
462
+
463
+ Dir.entries(namespaces_dir)
464
+ .select { |entry| File.directory?(File.join(namespaces_dir, entry)) && !entry.start_with?(".") }
465
+ .select { |entry| File.exist?(File.join(namespaces_dir, entry, ".namespace.yaml")) }
466
+ .sort
467
+ end
468
+
469
+ def fetch_container_status_batch(namespaces)
470
+ # Initialize cache if not exists
471
+ @container_status_cache ||= {}
472
+
473
+ # Group namespaces by hostname to batch SSH calls
474
+ namespace_by_host = {}
475
+
476
+ namespaces.each do |ns|
477
+ begin
478
+ config = load_namespace_config(ns)
479
+ hostname = config["hostname"]
480
+ username = config["username"]
481
+ host_key = username ? "#{username}@#{hostname}" : hostname
482
+
483
+ namespace_by_host[host_key] ||= { namespaces: [], config: config }
484
+ namespace_by_host[host_key][:namespaces] << ns
485
+ rescue StandardError
486
+ # Skip if namespace config not found
487
+ next
488
+ end
489
+ end
490
+
491
+ # Fetch status from each unique host in parallel
492
+ # Use Parallel.map to parallelize SSH calls across hosts
493
+ results = Parallel.map(namespace_by_host, in_threads: namespace_by_host.size) do |host_key, data|
494
+ fetch_status_from_host(host_key, data[:namespaces])
495
+ end
496
+
497
+ # Merge results into cache
498
+ results.each do |result|
499
+ @container_status_cache.merge!(result) if result
500
+ end
501
+ end
502
+
503
+ def fetch_status_from_host(remote_host, namespaces)
504
+ # Query all walheim-managed containers on this host
505
+ # Use docker ps with labels to get all containers in one call
506
+ # Use double quotes and escape for proper SSH transmission
507
+ docker_cmd = 'docker ps -a --filter label=walheim.managed=true --format "{{.Label \\"walheim.namespace\\"}}|{{.Label \\"walheim.app\\"}}|{{.State}}|{{.Status}}"'
508
+
509
+ ssh_command = "ssh #{remote_host} #{Shellwords.escape(docker_cmd)} 2>/dev/null"
510
+
511
+ output = `#{ssh_command}`
512
+
513
+ # Build result hash to return (thread-safe)
514
+ result = {}
515
+
516
+ # Parse output and populate result
517
+ # Format: namespace|appname|state|status
518
+ output.each_line do |line|
519
+ parts = line.strip.split("|")
520
+ next if parts.size < 4
521
+
522
+ ns = parts[0]
523
+ app_name = parts[1]
524
+ state = parts[2] # running, exited, paused, etc.
525
+ status_text = parts[3] # "Up 2 hours", "Exited (0) 5 minutes ago", etc.
526
+
527
+ # Only cache for namespaces we're querying
528
+ next unless namespaces.include?(ns)
529
+
530
+ cache_key = "#{ns}/#{app_name}"
531
+ result[cache_key] ||= { containers: [] }
532
+ result[cache_key][:containers] << {
533
+ state: state,
534
+ status: status_text
535
+ }
536
+ end
537
+
538
+ # Mark namespaces as queried (even if no containers found)
539
+ namespaces.each do |ns|
540
+ result["_queried_#{ns}"] = true
541
+ end
542
+
543
+ result
544
+ end
545
+
546
+ def get_container_status(namespace, app_name)
547
+ cache_key = "#{namespace}/#{app_name}"
548
+
549
+ # Check if we've queried this namespace
550
+ return "Unknown" unless @container_status_cache&.dig("_queried_#{namespace}")
551
+
552
+ # Get cached container data
553
+ cached_data = @container_status_cache&.dig(cache_key)
554
+ return "NotFound" if cached_data.nil? || cached_data[:containers].empty?
555
+
556
+ # Aggregate status from all containers
557
+ containers = cached_data[:containers]
558
+ states = containers.map { |c| c[:state] }.uniq
559
+
560
+ if states.all? { |s| s == "running" }
561
+ "Running"
562
+ elsif states.all? { |s| s == "exited" }
563
+ "Stopped"
564
+ elsif states.include?("running")
565
+ "Degraded"
566
+ elsif states.include?("paused")
567
+ "Paused"
568
+ elsif states.include?("restarting")
569
+ "Restarting"
570
+ else
571
+ "Unknown"
572
+ end
573
+ end
574
+
575
+ def get_container_ready(namespace, app_name)
576
+ cache_key = "#{namespace}/#{app_name}"
577
+
578
+ # Check if we've queried this namespace
579
+ return "-" unless @container_status_cache&.dig("_queried_#{namespace}")
580
+
581
+ # Get cached container data
582
+ cached_data = @container_status_cache&.dig(cache_key)
583
+ return "-" if cached_data.nil? || cached_data[:containers].empty?
584
+
585
+ # Count running vs total containers
586
+ containers = cached_data[:containers]
587
+ total = containers.size
588
+ running = containers.count { |c| c[:state] == "running" }
589
+
590
+ "#{running}/#{total}"
591
+ end
592
+
593
+ # Override get_single_resource to inject actual status and ready
594
+ def get_single_resource(namespace, name)
595
+ result = super
596
+
597
+ # Override status and ready with actual container data
598
+ if result[:summary]
599
+ result[:summary][:status] = get_container_status(namespace, name)
600
+ result[:summary][:ready] = get_container_ready(namespace, name)
601
+ end
602
+
603
+ result
604
+ end
605
+
236
606
  private
237
607
 
238
608
  def load_app_manifest(namespace, name)
@@ -28,7 +28,12 @@ module Resources
28
28
  get: {
29
29
  description: "List all namespaces",
30
30
  usage: [ "get namespaces" ],
31
- options: {} # No namespace flag for cluster resource
31
+ options: {}, # No namespace flag for cluster resource
32
+ dispatch: {
33
+ method: :get,
34
+ params: [ :name ],
35
+ output: :table
36
+ }
32
37
  },
33
38
  create: {
34
39
  description: "Create a new namespace",
@@ -36,6 +41,14 @@ module Resources
36
41
  options: {
37
42
  username: { type: :string, desc: "SSH username for namespace" },
38
43
  hostname: { type: :string, desc: "Hostname for namespace" }
44
+ },
45
+ dispatch: {
46
+ method: :create,
47
+ params: [ :name ],
48
+ named_params: {
49
+ username: :username,
50
+ hostname: :hostname
51
+ }
39
52
  }
40
53
  },
41
54
  apply: {
@@ -43,12 +56,32 @@ module Resources
43
56
  usage: [ "apply namespace {name}", "apply -f namespace.yaml" ],
44
57
  options: {
45
58
  file: { type: :string, aliases: [ :f ], desc: "Manifest file" }
59
+ },
60
+ dispatch: {
61
+ method: :apply,
62
+ params: [ :name ],
63
+ named_params: {
64
+ manifest_source: :file
65
+ }
46
66
  }
47
67
  },
48
68
  delete: {
49
69
  description: "Delete a namespace",
50
70
  usage: [ "delete namespace {name}" ],
51
- options: {}
71
+ options: {},
72
+ dispatch: {
73
+ method: :delete,
74
+ params: [ :name ]
75
+ }
76
+ },
77
+ describe: {
78
+ description: "Show detailed namespace information",
79
+ usage: [ "describe namespace {name}" ],
80
+ options: {},
81
+ dispatch: {
82
+ method: :describe,
83
+ params: [ :name ]
84
+ }
52
85
  }
53
86
  }
54
87
  end
@@ -79,8 +112,218 @@ module Resources
79
112
  puts " Hostname: #{hostname}"
80
113
  end
81
114
 
115
+ # Describe a namespace with detailed information
116
+ def describe(name:)
117
+ namespace_path = File.join(@data_dir, "namespaces", name)
118
+ unless Dir.exist?(namespace_path)
119
+ warn "Error: namespace '#{name}' not found"
120
+ exit 1
121
+ end
122
+
123
+ # Load namespace config
124
+ config_path = File.join(namespace_path, ".namespace.yaml")
125
+ unless File.exist?(config_path)
126
+ warn "Error: namespace config not found at #{config_path}"
127
+ exit 1
128
+ end
129
+
130
+ config = YAML.load_file(config_path)
131
+ username = config["username"]
132
+ hostname = config["hostname"]
133
+ remote_host = username ? "#{username}@#{hostname}" : hostname
134
+
135
+ # Print metadata
136
+ puts "Name: #{name}"
137
+ puts "Hostname: #{hostname}"
138
+ puts "Username: #{username || '(from SSH config)'}"
139
+ puts "SSH: #{remote_host}"
140
+ puts ""
141
+
142
+ # Test SSH connectivity and Docker availability
143
+ puts "Status:"
144
+ connection_status = test_ssh_connection(remote_host)
145
+ puts " Connection: #{connection_status[:status]}"
146
+
147
+ if connection_status[:connected]
148
+ docker_info = get_docker_info(remote_host)
149
+ puts " Docker: #{docker_info[:status]}"
150
+ puts ""
151
+
152
+ # Get deployed apps with status
153
+ apps_info = get_deployed_apps(name, remote_host)
154
+ if apps_info[:apps].any?
155
+ puts "Deployed Apps:"
156
+ apps_info[:apps].each do |app|
157
+ status_display = format_app_status(app[:status])
158
+ ready_display = app[:ready] || "-"
159
+ puts " %-15s %-12s %s" % [ app[:name], status_display, ready_display ]
160
+ end
161
+ puts ""
162
+ end
163
+
164
+ # Count resources
165
+ puts "Resources:"
166
+ apps_count = count_resources(namespace_path, "apps")
167
+ secrets_count = count_resources(namespace_path, "secrets")
168
+ configmaps_count = count_resources(namespace_path, "configmaps")
169
+ puts " Apps: #{apps_count}"
170
+ puts " Secrets: #{secrets_count}"
171
+ puts " ConfigMaps: #{configmaps_count}"
172
+ puts ""
173
+
174
+ # Show usage info if available
175
+ if docker_info[:connected]
176
+ usage_info = get_usage_info(remote_host)
177
+ if usage_info[:available]
178
+ puts "Usage:"
179
+ puts " Disk: #{usage_info[:disk]}" if usage_info[:disk]
180
+ puts " Containers: #{usage_info[:containers]}" if usage_info[:containers]
181
+ end
182
+ end
183
+ else
184
+ puts ""
185
+ puts "Unable to connect to namespace. Please check SSH configuration."
186
+ end
187
+ end
188
+
82
189
  private
83
190
 
191
+ # Test SSH connection to remote host
192
+ def test_ssh_connection(remote_host)
193
+ # Try a simple SSH command with timeout
194
+ test_command = "ssh -o ConnectTimeout=5 -o BatchMode=yes #{remote_host} 'echo ok' 2>/dev/null"
195
+ result = `#{test_command}`.strip
196
+
197
+ if result == "ok"
198
+ { connected: true, status: "Connected" }
199
+ else
200
+ { connected: false, status: "Failed" }
201
+ end
202
+ end
203
+
204
+ # Get Docker version and availability
205
+ def get_docker_info(remote_host)
206
+ docker_command = "ssh -o ConnectTimeout=5 #{remote_host} 'docker --version' 2>/dev/null"
207
+ result = `#{docker_command}`.strip
208
+
209
+ if result.include?("Docker version")
210
+ version = result.match(/Docker version ([\d.]+)/)[1] rescue "unknown"
211
+ { connected: true, status: "Available (v#{version})" }
212
+ else
213
+ { connected: false, status: "Not available" }
214
+ end
215
+ end
216
+
217
+ # Get list of deployed apps with their status
218
+ def get_deployed_apps(namespace, remote_host)
219
+ require "shellwords"
220
+
221
+ # Query all containers for this namespace
222
+ docker_cmd = "docker ps -a --filter label=walheim.namespace=" + namespace + ' --format "{{.Label \\"walheim.app\\"}}|{{.State}}|{{.Status}}"'
223
+ ssh_command = "ssh #{remote_host} #{Shellwords.escape(docker_cmd)} 2>/dev/null"
224
+ output = `#{ssh_command}`
225
+
226
+ # Parse container data
227
+ apps_data = {}
228
+ output.each_line do |line|
229
+ parts = line.strip.split("|")
230
+ next if parts.size < 3
231
+
232
+ app_name = parts[0]
233
+ state = parts[1]
234
+ status_text = parts[2]
235
+
236
+ apps_data[app_name] ||= { containers: [] }
237
+ apps_data[app_name][:containers] << { state: state, status: status_text }
238
+ end
239
+
240
+ # Convert to app list with aggregated status
241
+ apps = apps_data.map do |app_name, data|
242
+ containers = data[:containers]
243
+ total = containers.size
244
+ running = containers.count { |c| c[:state] == "running" }
245
+ ready = "#{running}/#{total}"
246
+
247
+ # Determine overall status
248
+ states = containers.map { |c| c[:state] }.uniq
249
+ status = if states.all? { |s| s == "running" }
250
+ "Running"
251
+ elsif states.all? { |s| s == "exited" }
252
+ "Stopped"
253
+ elsif states.include?("running")
254
+ "Degraded"
255
+ elsif states.include?("paused")
256
+ "Paused"
257
+ else
258
+ "Unknown"
259
+ end
260
+
261
+ { name: app_name, status: status, ready: ready }
262
+ end
263
+
264
+ { apps: apps.sort_by { |a| a[:name] } }
265
+ end
266
+
267
+ # Format app status for display
268
+ def format_app_status(status)
269
+ case status
270
+ when "Running"
271
+ status
272
+ when "Degraded"
273
+ status
274
+ when "Stopped"
275
+ status
276
+ when "Paused"
277
+ status
278
+ else
279
+ status
280
+ end
281
+ end
282
+
283
+ # Count resources in a namespace directory
284
+ def count_resources(namespace_path, resource_type)
285
+ resource_dir = File.join(namespace_path, resource_type)
286
+ return 0 unless Dir.exist?(resource_dir)
287
+
288
+ Dir.entries(resource_dir)
289
+ .select { |entry| File.directory?(File.join(resource_dir, entry)) && !entry.start_with?(".") }
290
+ .size
291
+ end
292
+
293
+ # Get usage information from remote host
294
+ def get_usage_info(remote_host)
295
+ # Get disk usage
296
+ disk_command = "ssh #{remote_host} 'df -h /data 2>/dev/null | tail -1' 2>/dev/null"
297
+ disk_output = `#{disk_command}`.strip
298
+ disk_info = nil
299
+ if disk_output && !disk_output.empty?
300
+ parts = disk_output.split
301
+ disk_info = "#{parts[2]} / #{parts[1]}" if parts.size >= 4
302
+ end
303
+
304
+ # Get container counts
305
+ container_command = "ssh #{remote_host} 'docker ps -q | wc -l; docker ps -aq | wc -l' 2>/dev/null"
306
+ container_output = `#{container_command}`.strip
307
+ container_info = nil
308
+ if container_output && !container_output.empty?
309
+ lines = container_output.split("\n")
310
+ if lines.size == 2
311
+ running = lines[0].strip.to_i
312
+ total = lines[1].strip.to_i
313
+ stopped = total - running
314
+ container_info = "#{running} running"
315
+ container_info += ", #{stopped} stopped" if stopped > 0
316
+ end
317
+ end
318
+
319
+ {
320
+ available: disk_info || container_info,
321
+ disk: disk_info,
322
+ containers: container_info
323
+ }
324
+ end
325
+
326
+
84
327
  def manifest_filename
85
328
  ".namespace.yaml"
86
329
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Walheim
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: walheim
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Akhyar Amarullah
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '1.3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: parallel
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.24'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.24'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: bundler
43
57
  requirement: !ruby/object:Gem::Requirement