walheim 0.1.2 → 0.2.1

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.
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers"
4
+
5
+ module Walheim
6
+ module BaseCommand
7
+ def self.execute(operation:, kind:, name:, options:, parent_options:)
8
+ # 1. Resolve handler
9
+ handler_info = Walheim::HandlerRegistry.get(kind)
10
+ unless handler_info
11
+ warn "Error: unknown kind '#{kind}'"
12
+ warn ""
13
+ warn "Available kinds:"
14
+ Walheim::HandlerRegistry.all_visible.each { |h| warn " #{h[:name]}" }
15
+ exit 1
16
+ end
17
+
18
+ # 2. Check operation support
19
+ unless Walheim::HandlerRegistry.supports_operation?(kind, operation)
20
+ warn "Error: #{operation} not supported for #{kind}"
21
+ exit 1
22
+ end
23
+
24
+ # 3. Resolve data directory from context
25
+ data_dir = resolve_data_dir(options, parent_options)
26
+
27
+ # 4. Initialize handler
28
+ handler = handler_info[:handler].new(data_dir: data_dir)
29
+
30
+ # 5. Validate namespace requirements
31
+ validate_namespace_options!(operation, kind, name, options) if handler.is_a?(Walheim::NamespacedResource)
32
+
33
+ # 6. Dispatch to handler
34
+ dispatch_to_handler(handler, operation, name, options, handler_info)
35
+ end
36
+
37
+ def self.resolve_data_dir(options, parent_options)
38
+ # Merge options
39
+ all_options = parent_options.merge(options)
40
+
41
+ # Try to load config
42
+ begin
43
+ config = Walheim::Config.new(config_path: all_options[:whconfig])
44
+
45
+ context_name = all_options[:context] || config.current_context
46
+
47
+ if context_name
48
+ config.data_dir(context_name)
49
+ elsif all_options[:data_dir]
50
+ warn "Warning: --data-dir is deprecated. Use contexts."
51
+ all_options[:data_dir]
52
+ else
53
+ warn "Error: No Walheim configuration found."
54
+ warn ""
55
+ warn "Create your first context:"
56
+ warn " whctl context new <name> --data-dir <path>"
57
+ exit 1
58
+ end
59
+ rescue Walheim::Config::ConfigError, Walheim::Config::ValidationError
60
+ if all_options[:data_dir]
61
+ warn "Warning: --data-dir is deprecated."
62
+ all_options[:data_dir]
63
+ else
64
+ warn "Error: No Walheim configuration found."
65
+ warn ""
66
+ warn "Create your first context:"
67
+ warn " whctl context new <name> --data-dir <path>"
68
+ exit 1
69
+ end
70
+ end
71
+ end
72
+
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)
77
+
78
+ # get can use --all
79
+ if operation == :get
80
+ return if options[:all] || options[:namespace]
81
+
82
+ 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"
85
+ exit 1
86
+ end
87
+
88
+ # Other operations require namespace
89
+ return if options[:namespace]
90
+
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)
119
+ else
120
+ warn "Error: operation #{operation} not implemented"
121
+ exit 1
122
+ end
123
+ end
124
+
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)
132
+ else
133
+ handler.get(namespace: options[:namespace], name: name)
134
+ end
135
+ Walheim::Helpers.print_resources_table(result, options[:all], handler_info[:name])
136
+ end
137
+ end
138
+
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])
143
+
144
+ if handler.is_a?(Walheim::NamespacedResource)
145
+ namespace = manifest_data["metadata"]["namespace"]
146
+ name = manifest_data["metadata"]["name"]
147
+
148
+ unless namespace && name
149
+ warn "Error: Manifest must contain metadata.namespace and metadata.name"
150
+ exit 1
151
+ end
152
+
153
+ handler.apply(namespace: namespace, name: name, manifest_source: options[:file])
154
+ 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])
162
+ 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
+ else
167
+ handler.apply(name: name)
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "terminal-table"
4
+ require "yaml"
5
+
6
+ module Walheim
7
+ module Helpers
8
+ # Print resources table for namespaced resources
9
+ def self.print_resources_table(result, all_namespaces, kind_name)
10
+ if result.is_a?(Hash)
11
+ # Single resource - print YAML
12
+ puts YAML.dump(result[:manifest])
13
+ return
14
+ end
15
+
16
+ if result.empty?
17
+ puts all_namespaces ? "No #{kind_name} found" : "No #{kind_name} found in namespace"
18
+ return
19
+ end
20
+
21
+ # Extract summary field names from first result
22
+ summary_fields = result.first[:summary].keys
23
+
24
+ # Build header row
25
+ headers = if all_namespaces
26
+ %w[NAMESPACE NAME] + summary_fields.map(&:to_s).map(&:upcase)
27
+ else
28
+ [ "NAME" ] + summary_fields.map(&:to_s).map(&:upcase)
29
+ end
30
+
31
+ # Build data rows
32
+ rows = result.map do |resource|
33
+ row = if all_namespaces
34
+ [ resource[:namespace], resource[:name] ]
35
+ else
36
+ [ resource[:name] ]
37
+ end
38
+
39
+ # Add summary field values
40
+ summary_values = summary_fields.map { |field| resource[:summary][field] || "N/A" }
41
+ row + summary_values
42
+ end
43
+
44
+ all_rows = [ headers ] + rows
45
+
46
+ table = Terminal::Table.new do |t|
47
+ t.rows = all_rows
48
+ t.style = {
49
+ border_x: "", border_y: "", border_i: "",
50
+ padding_left: 0, padding_right: 3,
51
+ border_top: false, border_bottom: false,
52
+ all_separators: false
53
+ }
54
+ end
55
+
56
+ puts table
57
+ end
58
+
59
+ # Print resources table for cluster resources
60
+ def self.print_cluster_resources_table(result, kind_name)
61
+ if result.is_a?(Hash)
62
+ # Single resource - print YAML
63
+ puts YAML.dump(result[:manifest])
64
+ return
65
+ end
66
+
67
+ if result.empty?
68
+ puts "No #{kind_name} found"
69
+ return
70
+ end
71
+
72
+ # Extract summary field names from first result
73
+ summary_fields = result.first[:summary].keys
74
+
75
+ # Build header row (no NAMESPACE column for cluster resources)
76
+ headers = [ "NAME" ] + summary_fields.map(&:to_s).map(&:upcase)
77
+
78
+ # Build data rows
79
+ rows = result.map do |resource|
80
+ row = [ resource[:name] ]
81
+
82
+ # Add summary field values
83
+ summary_values = summary_fields.map { |field| resource[:summary][field] || "N/A" }
84
+ row + summary_values
85
+ end
86
+
87
+ all_rows = [ headers ] + rows
88
+
89
+ table = Terminal::Table.new do |t|
90
+ t.rows = all_rows
91
+ t.style = {
92
+ border_x: "", border_y: "", border_i: "",
93
+ padding_left: 0, padding_right: 3,
94
+ border_top: false, border_bottom: false,
95
+ all_separators: false
96
+ }
97
+ end
98
+
99
+ puts table
100
+ end
101
+
102
+ # Read YAML from file or stdin
103
+ def self.read_yaml_input(file_path)
104
+ if file_path == "-"
105
+ # Read from stdin
106
+ YAML.safe_load($stdin.read)
107
+ else
108
+ # Read from file
109
+ YAML.load_file(file_path)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "terminal-table"
5
+
6
+ module Walheim
7
+ module LegacyContext
8
+ def self.execute(argv)
9
+ options = {}
10
+
11
+ # Remove 'context' from argv since it's already been identified
12
+ argv.shift if argv[0] == "context"
13
+
14
+ # Parse global flags
15
+ global_parser = OptionParser.new do |opts|
16
+ opts.on("--whconfig PATH", "Alternate config file path") { |v| options[:whconfig] = v }
17
+ opts.on("-d DIR", "--data-dir DIR", "Data directory") { |v| options[:data_dir] = v }
18
+ end
19
+
20
+ # Parse global flags (use parse! to consume all flags)
21
+ begin
22
+ global_parser.parse!(argv)
23
+ rescue OptionParser::InvalidOption => e
24
+ warn "Error: #{e.message}"
25
+ exit 1
26
+ end
27
+
28
+ # Get subcommand and context name (now argv only contains non-flag arguments)
29
+ subcommand = argv[0]
30
+ context_name = argv[1]
31
+
32
+ case subcommand
33
+ when "new"
34
+ # whctl context new {name} --data-dir {path}
35
+ unless context_name
36
+ warn "Error: context name is required"
37
+ warn "Usage: whctl context new {name} --data-dir {path}"
38
+ exit 1
39
+ end
40
+
41
+ unless options[:data_dir]
42
+ warn "Error: --data-dir flag is required"
43
+ warn "Usage: whctl context new {name} --data-dir {path}"
44
+ exit 1
45
+ end
46
+
47
+ # Validate data_dir exists
48
+ expanded_data_dir = File.expand_path(options[:data_dir])
49
+ unless Dir.exist?(expanded_data_dir)
50
+ warn "Error: data directory '#{expanded_data_dir}' does not exist"
51
+ warn ""
52
+ warn "Please create the directory first or provide a valid path"
53
+ exit 1
54
+ end
55
+
56
+ # Check for namespaces subdirectory
57
+ namespaces_path = File.join(expanded_data_dir, "namespaces")
58
+ unless Dir.exist?(namespaces_path)
59
+ warn "Warning: data directory does not contain 'namespaces' subdirectory"
60
+ warn "Expected path: #{namespaces_path}"
61
+ warn ""
62
+ warn "This directory should contain your homelab configuration."
63
+ warn "Creating namespaces directory..."
64
+ Dir.mkdir(namespaces_path)
65
+ end
66
+
67
+ # Load or create config
68
+ config = Walheim::Config.new(config_path: options[:whconfig])
69
+
70
+ begin
71
+ config.add_context(context_name, expanded_data_dir, activate: true)
72
+ config.save_config
73
+ puts "Created context '#{context_name}'"
74
+ puts " Data directory: #{expanded_data_dir}"
75
+ puts " Status: Active (automatically activated)"
76
+ rescue Walheim::Config::ValidationError => e
77
+ warn "Error: #{e.message}"
78
+ exit 1
79
+ rescue Walheim::Config::ConfigError => e
80
+ warn "Error: #{e.message}"
81
+ exit 1
82
+ end
83
+
84
+ when "list"
85
+ # whctl context list
86
+ config = Walheim::Config.new(config_path: options[:whconfig])
87
+
88
+ unless Walheim::Config.exists?(config_path: options[:whconfig])
89
+ warn "No Walheim configuration found."
90
+ warn ""
91
+ warn "Create your first context:"
92
+ warn " whctl context new <name> --data-dir <path>"
93
+ exit 1
94
+ end
95
+
96
+ contexts = config.list_contexts
97
+ if contexts.empty?
98
+ puts "No contexts configured."
99
+ exit 0
100
+ end
101
+
102
+ rows = contexts.map do |ctx|
103
+ active_marker = ctx["active"] ? "*" : ""
104
+ [ active_marker, ctx["name"], ctx["dataDir"] ]
105
+ end
106
+
107
+ table = Terminal::Table.new do |t|
108
+ t.headings = [ "CURRENT", "NAME", "DATA DIRECTORY" ]
109
+ t.rows = rows
110
+ t.style = {
111
+ border_x: "", border_y: "", border_i: "",
112
+ padding_left: 0, padding_right: 3,
113
+ border_top: false, border_bottom: false,
114
+ all_separators: false
115
+ }
116
+ end
117
+
118
+ puts table
119
+
120
+ when "use"
121
+ # whctl context use {name}
122
+ unless context_name
123
+ warn "Error: context name is required"
124
+ warn "Usage: whctl context use {name}"
125
+ exit 1
126
+ end
127
+
128
+ config = Walheim::Config.new(config_path: options[:whconfig])
129
+
130
+ begin
131
+ config.use_context(context_name)
132
+ config.save_config
133
+ puts "Switched to context '#{context_name}'"
134
+ rescue Walheim::Config::ConfigError => e
135
+ warn "Error: #{e.message}"
136
+ exit 1
137
+ end
138
+
139
+ when "current"
140
+ # whctl context current
141
+ config = Walheim::Config.new(config_path: options[:whconfig])
142
+
143
+ unless Walheim::Config.exists?(config_path: options[:whconfig])
144
+ warn "No Walheim configuration found."
145
+ warn ""
146
+ warn "Create your first context:"
147
+ warn " whctl context new <name> --data-dir <path>"
148
+ exit 1
149
+ end
150
+
151
+ if config.current_context
152
+ puts "Current context: #{config.current_context}"
153
+ puts "Data directory: #{config.data_dir}"
154
+ else
155
+ puts "No active context selected."
156
+ puts ""
157
+ puts "Available contexts:"
158
+ config.list_contexts.each do |ctx|
159
+ puts " - #{ctx['name']}"
160
+ end
161
+ puts ""
162
+ puts "Select a context:"
163
+ puts " whctl context use <context-name>"
164
+ end
165
+
166
+ when "delete"
167
+ # whctl context delete {name}
168
+ unless context_name
169
+ warn "Error: context name is required"
170
+ warn "Usage: whctl context delete {name}"
171
+ exit 1
172
+ end
173
+
174
+ config = Walheim::Config.new(config_path: options[:whconfig])
175
+
176
+ begin
177
+ config.delete_context(context_name)
178
+ config.save_config
179
+ puts "Deleted context '#{context_name}'"
180
+
181
+ # Show warning if this was the active context
182
+ unless config.current_context
183
+ puts ""
184
+ puts "Note: This was your active context."
185
+ puts "Select a new context with:"
186
+ puts " whctl context use <context-name>"
187
+ end
188
+ rescue Walheim::Config::ConfigError => e
189
+ warn "Error: #{e.message}"
190
+ exit 1
191
+ end
192
+
193
+ else
194
+ warn "Error: unknown context subcommand '#{subcommand}'"
195
+ warn ""
196
+ warn "Available context commands:"
197
+ warn " whctl context new {name} --data-dir {path}"
198
+ warn " whctl context list"
199
+ warn " whctl context use {name}"
200
+ warn " whctl context current"
201
+ warn " whctl context delete {name}"
202
+ exit 1
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+
5
+ module Walheim
6
+ module ResourceCommand
7
+ def self.register_operation(cli_class, operation)
8
+ # Get handlers supporting this operation
9
+ handlers = Walheim::HandlerRegistry.handlers_for_operation(operation)
10
+ return if handlers.empty?
11
+
12
+ # Build command description
13
+ descriptions = handlers.map { |h| h[:name] }.join(", ")
14
+ desc_text = "#{operation.to_s.capitalize} resources (#{descriptions})"
15
+
16
+ # Define Thor command
17
+ cli_class.desc "#{operation} KIND [NAME]", desc_text
18
+
19
+ # Add operation-specific options from handler metadata
20
+ # Collect all unique options across handlers for this operation
21
+ all_options = {}
22
+ handlers.each do |handler_info|
23
+ handler_class = handler_info[:handler]
24
+ next unless handler_class.respond_to?(:operation_info)
25
+
26
+ op_metadata = handler_class.operation_info[operation]
27
+ all_options.merge!(op_metadata[:options]) if op_metadata && op_metadata[:options]
28
+ end
29
+
30
+ # Register options with Thor
31
+ all_options.each do |opt_name, opt_config|
32
+ # Thor expects method_option calls
33
+ # We'll need to call this dynamically
34
+ cli_class.method_option opt_name, opt_config
35
+ end
36
+
37
+ # Define command method
38
+ cli_class.define_method(operation) do |kind, name = nil|
39
+ BaseCommand.execute(
40
+ operation: operation,
41
+ kind: kind,
42
+ name: name,
43
+ options: options,
44
+ parent_options: self.class.class_options.transform_keys(&:to_sym).transform_values do |v|
45
+ options[v.name]
46
+ rescue StandardError
47
+ nil
48
+ end
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require_relative "cli/helpers"
5
+ require_relative "cli/base_command"
6
+ require_relative "cli/resource_command"
7
+
8
+ module Walheim
9
+ class CLI < Thor
10
+ # Global flags
11
+ class_option :context,
12
+ type: :string,
13
+ desc: "Override active context"
14
+
15
+ class_option :whconfig,
16
+ type: :string,
17
+ desc: "Alternate config file path"
18
+
19
+ class_option :data_dir,
20
+ type: :string,
21
+ aliases: [ :d ],
22
+ desc: "Data directory (deprecated: use contexts)"
23
+
24
+ # Dynamically register operations
25
+ def self.register_operations
26
+ Walheim::HandlerRegistry.all_operations.each do |operation|
27
+ ResourceCommand.register_operation(self, operation)
28
+ end
29
+ end
30
+
31
+ # Version command
32
+ desc "version", "Show version"
33
+ def version
34
+ puts "whctl version #{Walheim::VERSION}"
35
+ end
36
+
37
+ # Override help to maintain kubectl-style help
38
+ def self.help(shell, subcommand = false)
39
+ list = printable_commands(true, subcommand)
40
+ Thor::Util.thor_classes_in(self).each do |klass|
41
+ list += klass.printable_commands(false)
42
+ end
43
+
44
+ # 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 ""
54
+
55
+ # Print commands
56
+ list.each do |command|
57
+ next if command[0] == "help"
58
+
59
+ shell.say " #{command[0].ljust(30)} #{command[1]}"
60
+ end
61
+ end
62
+ end
63
+ 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