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.
@@ -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
@@ -50,6 +50,32 @@ module Walheim
50
50
  handler_class = handler_info[:handler]
51
51
  handler_class.public_instance_methods.include?(operation.to_sym)
52
52
  end
53
+
54
+ # Get all unique operations across all handlers
55
+ def all_operations
56
+ ops = {}
57
+ handlers.values.uniq { |h| h[:handler] }.each do |handler_info|
58
+ handler_class = handler_info[:handler]
59
+ # Get operations from operation_info metadata
60
+ handler_class.operation_info.each_key { |op| ops[op] = true } if handler_class.respond_to?(:operation_info)
61
+ end
62
+ ops.keys.sort
63
+ end
64
+
65
+ # Get handlers supporting a specific operation
66
+ def handlers_for_operation(operation)
67
+ all_visible.select do |handler_info|
68
+ supports_operation?(handler_info[:name], operation)
69
+ end
70
+ end
71
+
72
+ # Check if handler is cluster or namespaced resource
73
+ def cluster_resource?(kind)
74
+ handler_info = get(kind)
75
+ return false unless handler_info
76
+
77
+ handler_info[:handler] < Walheim::ClusterResource
78
+ end
53
79
  end
54
80
  end
55
81
  end
@@ -1,19 +1,46 @@
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
11
+ def self.operation_info
12
+ ops = super
13
+
14
+ # Add namespace flags to all operations
15
+ namespace_options = {
16
+ namespace: {
17
+ type: :string,
18
+ aliases: [ :n ],
19
+ desc: "Target namespace",
20
+ required: false # Validated at runtime
21
+ },
22
+ all: {
23
+ type: :boolean,
24
+ aliases: [ :A ],
25
+ desc: "All namespaces",
26
+ banner: "" # No value needed for boolean
27
+ }
28
+ }
29
+
30
+ ops[:get][:options].merge!(namespace_options)
31
+ ops[:apply][:options].merge!(namespace: namespace_options[:namespace])
32
+ ops[:delete][:options].merge!(namespace: namespace_options[:namespace])
33
+
34
+ ops
35
+ end
36
+
10
37
  # CRUD operations for namespace-scoped resources
11
38
 
12
39
  def apply(namespace:, name:, manifest_source: nil)
13
40
  manifest_data = if manifest_source
14
- File.read(manifest_source)
41
+ File.read(manifest_source)
15
42
  else
16
- read_manifest_from_db(namespace, name)
43
+ read_manifest_from_db(namespace, name)
17
44
  end
18
45
 
19
46
  if resource_exists?(namespace, name)
@@ -78,7 +105,7 @@ module Walheim
78
105
  # Get single resource
79
106
  get_single_resource(namespace, name)
80
107
  else
81
- raise ArgumentError, 'namespace is required when name is specified'
108
+ raise ArgumentError, "namespace is required when name is specified"
82
109
  end
83
110
  end
84
111
 
@@ -92,7 +119,7 @@ module Walheim
92
119
 
93
120
  manifest_path = File.join(resource_dir(namespace, name), manifest_filename)
94
121
  manifest_content = File.read(manifest_path)
95
- manifest_data = YAML.load(manifest_content)
122
+ manifest_data = YAML.safe_load(manifest_content)
96
123
 
97
124
  # Compute summary fields
98
125
  summary = {}
@@ -110,7 +137,7 @@ module Walheim
110
137
 
111
138
  # Path to resource directory: {data_dir}/namespaces/{namespace}/{kind_plural}/{name}/
112
139
  def resource_dir(namespace, name)
113
- File.join(@data_dir, 'namespaces', namespace, self.class.kind_info[:plural], name)
140
+ File.join(@data_dir, "namespaces", namespace, self.class.kind_info[:plural], name)
114
141
  end
115
142
 
116
143
  def resource_exists?(namespace, name)
@@ -142,7 +169,7 @@ module Walheim
142
169
  end
143
170
 
144
171
  def list_single_namespace(namespace)
145
- namespaces_dir = File.join(@data_dir, 'namespaces')
172
+ namespaces_dir = File.join(@data_dir, "namespaces")
146
173
  namespace_path = File.join(namespaces_dir, namespace)
147
174
 
148
175
  unless Dir.exist?(namespace_path)
@@ -155,7 +182,10 @@ module Walheim
155
182
 
156
183
  # Find all resource directories
157
184
  resource_names = Dir.entries(resources_path)
158
- .select { |entry| File.directory?(File.join(resources_path, entry)) && !entry.start_with?('.') }
185
+ .select do |entry|
186
+ File.directory?(File.join(resources_path,
187
+ entry)) && !entry.start_with?(".")
188
+ end
159
189
  .sort
160
190
 
161
191
  # Return array of manifest hashes
@@ -165,13 +195,16 @@ module Walheim
165
195
  end
166
196
 
167
197
  def list_all_namespaces
168
- namespaces_dir = File.join(@data_dir, 'namespaces')
198
+ namespaces_dir = File.join(@data_dir, "namespaces")
169
199
  return [] unless Dir.exist?(namespaces_dir)
170
200
 
171
201
  # Find all namespaces
172
202
  namespace_names = Dir.entries(namespaces_dir)
173
- .select { |entry| File.directory?(File.join(namespaces_dir, entry)) && !entry.start_with?('.') }
174
- .select { |entry| File.exist?(File.join(namespaces_dir, entry, '.namespace.yaml')) }
203
+ .select do |entry|
204
+ File.directory?(File.join(namespaces_dir,
205
+ entry)) && !entry.start_with?(".")
206
+ end
207
+ .select { |entry| File.exist?(File.join(namespaces_dir, entry, ".namespace.yaml")) }
175
208
  .sort
176
209
 
177
210
  # Collect all resources from all namespaces
@@ -181,7 +214,10 @@ module Walheim
181
214
  next unless Dir.exist?(resources_path)
182
215
 
183
216
  resource_names = Dir.entries(resources_path)
184
- .select { |entry| File.directory?(File.join(resources_path, entry)) && !entry.start_with?('.') }
217
+ .select do |entry|
218
+ File.directory?(File.join(resources_path,
219
+ entry)) && !entry.start_with?(".")
220
+ end
185
221
  .sort
186
222
 
187
223
  resource_names.each do |resource_name|
@@ -189,7 +225,7 @@ module Walheim
189
225
  end
190
226
  end
191
227
 
192
- all_resources.sort_by { |resource| [resource[:namespace], resource[:name]] }
228
+ all_resources.sort_by { |resource| [ resource[:namespace], resource[:name] ] }
193
229
  end
194
230
  end
195
231
  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,7 +32,7 @@ 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
38
  # Operation metadata - defines how operations appear in help
@@ -40,20 +40,25 @@ module Walheim
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
50
  },
50
51
  apply: {
51
- description: 'Create or update a resource',
52
- usage: ["apply #{kind_info[:singular]} {name} -n {namespace}"]
52
+ description: "Create or update a resource",
53
+ usage: [ "apply #{kind_info[:singular]} {name} -n {namespace}" ],
54
+ options: {
55
+ file: { type: :string, aliases: [ :f ], desc: "Manifest file (use - for stdin)" }
56
+ }
53
57
  },
54
58
  delete: {
55
- description: 'Delete a resource',
56
- usage: ["delete #{kind_info[:singular]} {name} -n {namespace}"]
59
+ description: "Delete a resource",
60
+ usage: [ "delete #{kind_info[:singular]} {name} -n {namespace}" ],
61
+ options: {} # Subclasses will override
57
62
  }
58
63
  }
59
64
  end