walheim 0.2.0 → 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.
data/lib/walheim/cli.rb CHANGED
@@ -1,25 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'thor'
4
- require_relative 'cli/helpers'
5
- require_relative 'cli/base_command'
6
- require_relative 'cli/resource_command'
3
+ require "thor"
4
+ require_relative "cli/helpers"
5
+ require_relative "cli/base_command"
6
+ 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,
13
- desc: 'Override active context'
18
+ desc: "Override active context"
14
19
 
15
20
  class_option :whconfig,
16
21
  type: :string,
17
- desc: 'Alternate config file path'
22
+ desc: "Alternate config file path"
18
23
 
19
24
  class_option :data_dir,
20
25
  type: :string,
21
- aliases: [:d],
22
- desc: 'Data directory (deprecated: use contexts)'
26
+ aliases: [ :d ],
27
+ desc: "Data directory (deprecated: use contexts)"
23
28
 
24
29
  # Dynamically register operations
25
30
  def self.register_operations
@@ -29,11 +34,50 @@ module Walheim
29
34
  end
30
35
 
31
36
  # Version command
32
- desc 'version', 'Show version'
37
+ desc "version", "Show version"
33
38
  def version
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)
@@ -42,19 +86,20 @@ module Walheim
42
86
  end
43
87
 
44
88
  # Group commands by category
45
- shell.say 'Usage: whctl [global flags] <command> [arguments]'
46
- shell.say ''
47
- shell.say 'Global flags:'
48
- shell.say ' --context CONTEXT Override active context for this command'
49
- shell.say ' --whconfig PATH Use alternate config file (default: ~/.walheim/config)'
50
- shell.say ' -d, --data-dir DIR Data directory containing namespaces (deprecated: use contexts)'
51
- shell.say ''
52
- shell.say 'Available commands:'
53
- shell.say ''
89
+ shell.say "Usage: whctl [global flags] <command> [arguments]"
90
+ shell.say ""
91
+ shell.say "Global flags:"
92
+ shell.say " --context CONTEXT Override active context for this command"
93
+ shell.say " --whconfig PATH Use alternate config file (default: ~/.walheim/config)"
94
+ shell.say " -d, --data-dir DIR Data directory containing namespaces (deprecated: use contexts)"
95
+ shell.say ""
96
+ shell.say "Available commands:"
97
+ shell.say ""
54
98
 
55
99
  # Print commands
56
100
  list.each do |command|
57
- next if command[0] == 'help'
101
+ next if command[0] == "help"
102
+
58
103
  shell.say " #{command[0].ljust(30)} #{command[1]}"
59
104
  end
60
105
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'resource'
3
+ require_relative "resource"
4
4
 
5
5
  module Walheim
6
6
  # ClusterResource represents resources that are cluster-scoped (not namespaced)
@@ -69,9 +69,9 @@ module Walheim
69
69
  # Apply operation - create or update
70
70
  def apply(name:, manifest_source: nil)
71
71
  manifest_data = if manifest_source
72
- File.read(manifest_source)
72
+ File.read(manifest_source)
73
73
  else
74
- read_manifest_from_db(name)
74
+ read_manifest_from_db(name)
75
75
  end
76
76
 
77
77
  if resource_exists?(name)
@@ -91,7 +91,7 @@ module Walheim
91
91
 
92
92
  manifest_path = File.join(resource_dir(name), manifest_filename)
93
93
  manifest_content = File.read(manifest_path)
94
- manifest_data = YAML.load(manifest_content)
94
+ manifest_data = YAML.safe_load(manifest_content)
95
95
 
96
96
  # Compute summary fields
97
97
  summary = {}
@@ -123,7 +123,7 @@ module Walheim
123
123
  def find_resource_names
124
124
  base_dir = File.join(@data_dir, self.class.kind_info[:plural])
125
125
  Dir.entries(base_dir)
126
- .select { |entry| File.directory?(File.join(base_dir, entry)) && !entry.start_with?('.') }
126
+ .select { |entry| File.directory?(File.join(base_dir, entry)) && !entry.start_with?(".") }
127
127
  .select { |entry| File.exist?(File.join(base_dir, entry, manifest_filename)) }
128
128
  .sort
129
129
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'yaml'
4
- require 'fileutils'
3
+ require "yaml"
4
+ require "fileutils"
5
5
 
6
6
  module Walheim
7
7
  # Configuration management for Walheim contexts
@@ -10,9 +10,9 @@ module Walheim
10
10
  class ConfigError < StandardError; end
11
11
  class ValidationError < ConfigError; end
12
12
 
13
- DEFAULT_CONFIG_PATH = File.expand_path('~/.walheim/config')
14
- API_VERSION = 'walheim.io/v1'
15
- KIND = 'Config'
13
+ DEFAULT_CONFIG_PATH = File.expand_path("~/.walheim/config")
14
+ API_VERSION = "walheim.io/v1"
15
+ KIND = "Config"
16
16
 
17
17
  attr_reader :current_context, :contexts, :config_path
18
18
 
@@ -35,18 +35,18 @@ module Walheim
35
35
  data = YAML.load_file(@config_path)
36
36
  validate_schema!(data)
37
37
 
38
- @current_context = data['currentContext']
39
- @contexts = data['contexts'].map do |ctx|
38
+ @current_context = data["currentContext"]
39
+ @contexts = data["contexts"].map do |ctx|
40
40
  {
41
- 'name' => ctx['name'],
42
- 'dataDir' => expand_path(ctx['dataDir'])
41
+ "name" => ctx["name"],
42
+ "dataDir" => expand_path(ctx["dataDir"])
43
43
  }
44
44
  end
45
45
 
46
46
  validate_current_context!
47
47
  rescue Psych::SyntaxError => e
48
48
  raise ConfigError, "Invalid YAML in config file: #{e.message}"
49
- rescue => e
49
+ rescue StandardError => e
50
50
  raise ConfigError, "Failed to load config: #{e.message}"
51
51
  end
52
52
 
@@ -56,13 +56,13 @@ module Walheim
56
56
  # @raise [ConfigError] if file cannot be written
57
57
  def save_config
58
58
  data = {
59
- 'apiVersion' => API_VERSION,
60
- 'kind' => KIND,
61
- 'currentContext' => @current_context,
62
- 'contexts' => @contexts.map do |ctx|
59
+ "apiVersion" => API_VERSION,
60
+ "kind" => KIND,
61
+ "currentContext" => @current_context,
62
+ "contexts" => @contexts.map do |ctx|
63
63
  {
64
- 'name' => ctx['name'],
65
- 'dataDir' => ctx['dataDir']
64
+ "name" => ctx["name"],
65
+ "dataDir" => ctx["dataDir"]
66
66
  }
67
67
  end
68
68
  }
@@ -74,7 +74,7 @@ module Walheim
74
74
  temp_file = "#{@config_path}.tmp.#{Process.pid}"
75
75
  File.write(temp_file, YAML.dump(data))
76
76
  File.rename(temp_file, @config_path)
77
- rescue => e
77
+ rescue StandardError => e
78
78
  File.delete(temp_file) if temp_file && File.exist?(temp_file)
79
79
  raise ConfigError, "Failed to save config: #{e.message}"
80
80
  end
@@ -86,12 +86,12 @@ module Walheim
86
86
  # @raise [ConfigError] if context not found or no current context
87
87
  def data_dir(context_name = nil)
88
88
  name = context_name || @current_context
89
- raise ConfigError, 'No active context selected' if name.nil?
89
+ raise ConfigError, "No active context selected" if name.nil?
90
90
 
91
91
  context = find_context(name)
92
92
  raise ConfigError, "Context '#{name}' not found" if context.nil?
93
93
 
94
- context['dataDir']
94
+ context["dataDir"]
95
95
  end
96
96
 
97
97
  # Add a new context
@@ -105,8 +105,8 @@ module Walheim
105
105
  raise ValidationError, "Context '#{name}' already exists" if find_context(name)
106
106
 
107
107
  @contexts << {
108
- 'name' => name,
109
- 'dataDir' => expand_path(data_dir)
108
+ "name" => name,
109
+ "dataDir" => expand_path(data_dir)
110
110
  }
111
111
 
112
112
  @current_context = name if activate
@@ -134,6 +134,7 @@ module Walheim
134
134
  # @raise [ConfigError] if context not found
135
135
  def use_context(name)
136
136
  raise ConfigError, "Context '#{name}' not found" unless find_context(name)
137
+
137
138
  @current_context = name
138
139
  end
139
140
 
@@ -142,7 +143,7 @@ module Walheim
142
143
  # @return [Array<Hash>] Array of context hashes with 'name', 'dataDir', and 'active' keys
143
144
  def list_contexts
144
145
  @contexts.map do |ctx|
145
- ctx.merge('active' => ctx['name'] == @current_context)
146
+ ctx.merge("active" => ctx["name"] == @current_context)
146
147
  end
147
148
  end
148
149
 
@@ -159,7 +160,8 @@ module Walheim
159
160
  # Resolve the config file path with precedence: param > $WHCONFIG > default
160
161
  def resolve_config_path(config_path)
161
162
  return expand_path(config_path) if config_path
162
- return expand_path(ENV['WHCONFIG']) if ENV['WHCONFIG']
163
+ return expand_path(ENV["WHCONFIG"]) if ENV["WHCONFIG"]
164
+
163
165
  DEFAULT_CONFIG_PATH
164
166
  end
165
167
 
@@ -170,27 +172,27 @@ module Walheim
170
172
 
171
173
  # Find a context by name
172
174
  def find_context(name)
173
- @contexts.find { |ctx| ctx['name'] == name }
175
+ @contexts.find { |ctx| ctx["name"] == name }
174
176
  end
175
177
 
176
178
  # Validate config schema
177
179
  def validate_schema!(data)
178
- raise ValidationError, 'Config must be a Hash' unless data.is_a?(Hash)
179
- raise ValidationError, "Invalid apiVersion: expected '#{API_VERSION}'" unless data['apiVersion'] == API_VERSION
180
- raise ValidationError, "Invalid kind: expected '#{KIND}'" unless data['kind'] == KIND
181
- raise ValidationError, 'Missing required field: contexts' unless data['contexts']
182
- raise ValidationError, 'contexts must be an Array' unless data['contexts'].is_a?(Array)
183
- raise ValidationError, 'contexts array cannot be empty' if data['contexts'].empty?
180
+ raise ValidationError, "Config must be a Hash" unless data.is_a?(Hash)
181
+ raise ValidationError, "Invalid apiVersion: expected '#{API_VERSION}'" unless data["apiVersion"] == API_VERSION
182
+ raise ValidationError, "Invalid kind: expected '#{KIND}'" unless data["kind"] == KIND
183
+ raise ValidationError, "Missing required field: contexts" unless data["contexts"]
184
+ raise ValidationError, "contexts must be an Array" unless data["contexts"].is_a?(Array)
185
+ raise ValidationError, "contexts array cannot be empty" if data["contexts"].empty?
184
186
 
185
187
  # Validate each context
186
- data['contexts'].each_with_index do |ctx, index|
188
+ data["contexts"].each_with_index do |ctx, index|
187
189
  raise ValidationError, "Context at index #{index} must be a Hash" unless ctx.is_a?(Hash)
188
- raise ValidationError, "Context at index #{index} missing 'name'" unless ctx['name']
189
- raise ValidationError, "Context at index #{index} missing 'dataDir'" unless ctx['dataDir']
190
+ raise ValidationError, "Context at index #{index} missing 'name'" unless ctx["name"]
191
+ raise ValidationError, "Context at index #{index} missing 'dataDir'" unless ctx["dataDir"]
190
192
  end
191
193
 
192
194
  # Check for duplicate context names
193
- names = data['contexts'].map { |ctx| ctx['name'] }
195
+ names = data["contexts"].map { |ctx| ctx["name"] }
194
196
  duplicates = names.select { |name| names.count(name) > 1 }.uniq
195
197
  raise ValidationError, "Duplicate context names: #{duplicates.join(', ')}" unless duplicates.empty?
196
198
  end
@@ -57,9 +57,7 @@ module Walheim
57
57
  handlers.values.uniq { |h| h[:handler] }.each do |handler_info|
58
58
  handler_class = handler_info[:handler]
59
59
  # Get operations from operation_info metadata
60
- if handler_class.respond_to?(:operation_info)
61
- handler_class.operation_info.keys.each { |op| ops[op] = true }
62
- end
60
+ handler_class.operation_info.each_key { |op| ops[op] = true } if handler_class.respond_to?(:operation_info)
63
61
  end
64
62
  ops.keys.sort
65
63
  end
@@ -75,6 +73,7 @@ module Walheim
75
73
  def cluster_resource?(kind)
76
74
  handler_info = get(kind)
77
75
  return false unless handler_info
76
+
78
77
  handler_info[:handler] < Walheim::ClusterResource
79
78
  end
80
79
  end
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'resource'
3
+ require_relative "resource"
4
4
 
5
5
  module Walheim
6
6
  # NamespacedResource represents resources that are scoped to a namespace
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
 
@@ -15,15 +15,15 @@ module Walheim
15
15
  namespace_options = {
16
16
  namespace: {
17
17
  type: :string,
18
- aliases: [:n],
19
- desc: 'Target namespace',
20
- required: false # Validated at runtime
18
+ aliases: [ :n ],
19
+ desc: "Target namespace",
20
+ required: false # Validated at runtime
21
21
  },
22
22
  all: {
23
23
  type: :boolean,
24
- aliases: [:A],
25
- desc: 'All namespaces',
26
- banner: '' # No value needed for boolean
24
+ aliases: [ :A ],
25
+ desc: "All namespaces",
26
+ banner: "" # No value needed for boolean
27
27
  }
28
28
  }
29
29
 
@@ -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
 
@@ -38,9 +49,9 @@ module Walheim
38
49
 
39
50
  def apply(namespace:, name:, manifest_source: nil)
40
51
  manifest_data = if manifest_source
41
- File.read(manifest_source)
52
+ File.read(manifest_source)
42
53
  else
43
- read_manifest_from_db(namespace, name)
54
+ read_manifest_from_db(namespace, name)
44
55
  end
45
56
 
46
57
  if resource_exists?(namespace, name)
@@ -105,7 +116,7 @@ module Walheim
105
116
  # Get single resource
106
117
  get_single_resource(namespace, name)
107
118
  else
108
- raise ArgumentError, 'namespace is required when name is specified'
119
+ raise ArgumentError, "namespace is required when name is specified"
109
120
  end
110
121
  end
111
122
 
@@ -119,7 +130,7 @@ module Walheim
119
130
 
120
131
  manifest_path = File.join(resource_dir(namespace, name), manifest_filename)
121
132
  manifest_content = File.read(manifest_path)
122
- manifest_data = YAML.load(manifest_content)
133
+ manifest_data = YAML.safe_load(manifest_content)
123
134
 
124
135
  # Compute summary fields
125
136
  summary = {}
@@ -137,7 +148,7 @@ module Walheim
137
148
 
138
149
  # Path to resource directory: {data_dir}/namespaces/{namespace}/{kind_plural}/{name}/
139
150
  def resource_dir(namespace, name)
140
- File.join(@data_dir, 'namespaces', namespace, self.class.kind_info[:plural], name)
151
+ File.join(@data_dir, "namespaces", namespace, self.class.kind_info[:plural], name)
141
152
  end
142
153
 
143
154
  def resource_exists?(namespace, name)
@@ -169,7 +180,7 @@ module Walheim
169
180
  end
170
181
 
171
182
  def list_single_namespace(namespace)
172
- namespaces_dir = File.join(@data_dir, 'namespaces')
183
+ namespaces_dir = File.join(@data_dir, "namespaces")
173
184
  namespace_path = File.join(namespaces_dir, namespace)
174
185
 
175
186
  unless Dir.exist?(namespace_path)
@@ -182,7 +193,10 @@ module Walheim
182
193
 
183
194
  # Find all resource directories
184
195
  resource_names = Dir.entries(resources_path)
185
- .select { |entry| File.directory?(File.join(resources_path, entry)) && !entry.start_with?('.') }
196
+ .select do |entry|
197
+ File.directory?(File.join(resources_path,
198
+ entry)) && !entry.start_with?(".")
199
+ end
186
200
  .sort
187
201
 
188
202
  # Return array of manifest hashes
@@ -192,13 +206,16 @@ module Walheim
192
206
  end
193
207
 
194
208
  def list_all_namespaces
195
- namespaces_dir = File.join(@data_dir, 'namespaces')
209
+ namespaces_dir = File.join(@data_dir, "namespaces")
196
210
  return [] unless Dir.exist?(namespaces_dir)
197
211
 
198
212
  # Find all namespaces
199
213
  namespace_names = Dir.entries(namespaces_dir)
200
- .select { |entry| File.directory?(File.join(namespaces_dir, entry)) && !entry.start_with?('.') }
201
- .select { |entry| File.exist?(File.join(namespaces_dir, entry, '.namespace.yaml')) }
214
+ .select do |entry|
215
+ File.directory?(File.join(namespaces_dir,
216
+ entry)) && !entry.start_with?(".")
217
+ end
218
+ .select { |entry| File.exist?(File.join(namespaces_dir, entry, ".namespace.yaml")) }
202
219
  .sort
203
220
 
204
221
  # Collect all resources from all namespaces
@@ -208,7 +225,10 @@ module Walheim
208
225
  next unless Dir.exist?(resources_path)
209
226
 
210
227
  resource_names = Dir.entries(resources_path)
211
- .select { |entry| File.directory?(File.join(resources_path, entry)) && !entry.start_with?('.') }
228
+ .select do |entry|
229
+ File.directory?(File.join(resources_path,
230
+ entry)) && !entry.start_with?(".")
231
+ end
212
232
  .sort
213
233
 
214
234
  resource_names.each do |resource_name|
@@ -216,7 +236,7 @@ module Walheim
216
236
  end
217
237
  end
218
238
 
219
- all_resources.sort_by { |resource| [resource[:namespace], resource[:name]] }
239
+ all_resources.sort_by { |resource| [ resource[:namespace], resource[:name] ] }
220
240
  end
221
241
  end
222
242
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'yaml'
4
- require 'fileutils'
5
- require 'terminal-table'
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "terminal-table"
6
6
 
7
7
  module Walheim
8
8
  # Base Resource class containing common functionality for all resource types
@@ -18,7 +18,7 @@ module Walheim
18
18
 
19
19
  # Metadata - must be overridden by subclasses
20
20
  def self.kind_info
21
- raise NotImplementedError, 'Subclass must implement kind_info'
21
+ raise NotImplementedError, "Subclass must implement kind_info"
22
22
  end
23
23
 
24
24
  # Lifecycle hooks - can be overridden by subclasses
@@ -32,33 +32,49 @@ module Walheim
32
32
 
33
33
  # Summary fields for get output - can be overridden by subclasses
34
34
  def self.summary_fields
35
- {} # Default: no summary fields
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
  {
42
42
  get: {
43
- description: 'List or retrieve resources',
43
+ description: "List or retrieve resources",
44
44
  usage: [
45
45
  "get #{kind_info[:plural]} -n {namespace}",
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
- description: 'Create or update a resource',
53
- usage: ["apply #{kind_info[:singular]} {name} -n {namespace}"],
57
+ description: "Create or update a resource",
58
+ usage: [ "apply #{kind_info[:singular]} {name} -n {namespace}" ],
54
59
  options: {
55
- file: { type: :string, aliases: [:f], desc: 'Manifest file (use - for stdin)' }
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
- description: 'Delete a resource',
60
- usage: ["delete #{kind_info[:singular]} {name} -n {namespace}"],
61
- options: {} # Subclasses will override
71
+ description: "Delete a resource",
72
+ usage: [ "delete #{kind_info[:singular]} {name} -n {namespace}" ],
73
+ options: {}, # Subclasses will override
74
+ dispatch: {
75
+ method: :delete,
76
+ params: [ :name ]
77
+ }
62
78
  }
63
79
  }
64
80
  end