walheim 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'resource'
4
+
5
+ module Walheim
6
+ # ClusterResource represents resources that are cluster-scoped (not namespaced)
7
+ # Examples: Namespaces themselves
8
+ # These resources typically live at the top level (e.g., namespaces/{name}/)
9
+ class ClusterResource < Resource
10
+ # Get operation for cluster-scoped resources
11
+ # Returns list of all resources (no namespace filtering)
12
+ def get(name: nil)
13
+ if name.nil?
14
+ # List all cluster resources
15
+ list_all
16
+ else
17
+ # Get single cluster resource
18
+ get_single_resource(name)
19
+ end
20
+ end
21
+
22
+ # Create a cluster-scoped resource
23
+ def create(name:, manifest:)
24
+ # Validate manifest if validator exists
25
+ validate_manifest(manifest, name) if respond_to?(:validate_manifest, true)
26
+
27
+ # Create directory structure
28
+ ensure_resource_dir(name)
29
+
30
+ # Write manifest
31
+ write_manifest(name, manifest)
32
+
33
+ puts "Created #{self.class.kind_info[:singular]} '#{name}'"
34
+
35
+ # POST-CREATE HOOK
36
+ trigger_hook(:post_create, name: name)
37
+ end
38
+
39
+ # Update a cluster-scoped resource
40
+ def update(name:, manifest:)
41
+ # Validate manifest if validator exists
42
+ validate_manifest(manifest, name) if respond_to?(:validate_manifest, true)
43
+
44
+ # Write manifest (overwrite)
45
+ write_manifest(name, manifest)
46
+
47
+ puts "Updated #{self.class.kind_info[:singular]} '#{name}'"
48
+
49
+ # POST-UPDATE HOOK
50
+ trigger_hook(:post_update, name: name)
51
+ end
52
+
53
+ # Delete a cluster-scoped resource
54
+ def delete(name:)
55
+ unless resource_exists?(name)
56
+ warn "Error: #{self.class.kind_info[:singular]} '#{name}' not found"
57
+ exit 1
58
+ end
59
+
60
+ # PRE-DELETE HOOK
61
+ trigger_hook(:pre_delete, name: name)
62
+
63
+ # Delete from filesystem
64
+ remove_resource_dir(name)
65
+
66
+ puts "Deleted #{self.class.kind_info[:singular]} '#{name}'"
67
+ end
68
+
69
+ # Apply operation - create or update
70
+ def apply(name:, manifest_source: nil)
71
+ manifest_data = if manifest_source
72
+ File.read(manifest_source)
73
+ else
74
+ read_manifest_from_db(name)
75
+ end
76
+
77
+ if resource_exists?(name)
78
+ update(name: name, manifest: manifest_data)
79
+ else
80
+ create(name: name, manifest: manifest_data)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def get_single_resource(name)
87
+ unless resource_exists?(name)
88
+ warn "Error: #{self.class.kind_info[:singular]} '#{name}' not found"
89
+ exit 1
90
+ end
91
+
92
+ manifest_path = File.join(resource_dir(name), manifest_filename)
93
+ manifest_content = File.read(manifest_path)
94
+ manifest_data = YAML.load(manifest_content)
95
+
96
+ # Compute summary fields
97
+ summary = {}
98
+ self.class.summary_fields.each do |field_name, compute_fn|
99
+ summary[field_name] = compute_fn.call(manifest_data)
100
+ end
101
+
102
+ {
103
+ name: name,
104
+ manifest: manifest_data,
105
+ summary: summary
106
+ }
107
+ end
108
+
109
+ def list_all
110
+ base_dir = File.join(@data_dir, self.class.kind_info[:plural])
111
+ return [] unless Dir.exist?(base_dir)
112
+
113
+ # Find all resource directories
114
+ resource_names = find_resource_names
115
+
116
+ # Return array of resource hashes
117
+ resource_names.map do |name|
118
+ get_single_resource(name)
119
+ end
120
+ end
121
+
122
+ # Override in subclasses to customize how resources are discovered
123
+ def find_resource_names
124
+ base_dir = File.join(@data_dir, self.class.kind_info[:plural])
125
+ Dir.entries(base_dir)
126
+ .select { |entry| File.directory?(File.join(base_dir, entry)) && !entry.start_with?('.') }
127
+ .select { |entry| File.exist?(File.join(base_dir, entry, manifest_filename)) }
128
+ .sort
129
+ end
130
+
131
+ # Path to resource directory (cluster resources: {data_dir}/{kind_plural}/{name}/)
132
+ # For namespaces: {data_dir}/namespaces/{name}/
133
+ # For appsets: {data_dir}/appsets/{name}/
134
+ def resource_dir(name)
135
+ File.join(@data_dir, self.class.kind_info[:plural], name)
136
+ end
137
+
138
+ def resource_exists?(name)
139
+ manifest_path = File.join(resource_dir(name), manifest_filename)
140
+ File.exist?(manifest_path)
141
+ end
142
+
143
+ def ensure_resource_dir(name)
144
+ FileUtils.mkdir_p(resource_dir(name))
145
+ end
146
+
147
+ def write_manifest(name, manifest)
148
+ path = File.join(resource_dir(name), manifest_filename)
149
+ File.write(path, manifest)
150
+ end
151
+
152
+ def read_manifest_from_db(name)
153
+ path = File.join(resource_dir(name), manifest_filename)
154
+ unless File.exist?(path)
155
+ warn "Error: No manifest found at #{path}"
156
+ warn "Use 'whctl apply -f <file>' to create from a manifest file"
157
+ exit 1
158
+ end
159
+ File.read(path)
160
+ end
161
+
162
+ def remove_resource_dir(name)
163
+ FileUtils.rm_rf(resource_dir(name))
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+
6
+ module Walheim
7
+ # Configuration management for Walheim contexts
8
+ # Handles reading/writing ~/.walheim/config with support for $WHCONFIG override
9
+ class Config
10
+ class ConfigError < StandardError; end
11
+ class ValidationError < ConfigError; end
12
+
13
+ DEFAULT_CONFIG_PATH = File.expand_path('~/.walheim/config')
14
+ API_VERSION = 'walheim.io/v1'
15
+ KIND = 'Config'
16
+
17
+ attr_reader :current_context, :contexts, :config_path
18
+
19
+ # Initialize a new Config instance
20
+ #
21
+ # @param config_path [String, nil] Path to config file (defaults to ~/.walheim/config or $WHCONFIG)
22
+ def initialize(config_path: nil)
23
+ @config_path = resolve_config_path(config_path)
24
+ @current_context = nil
25
+ @contexts = []
26
+ load_config if File.exist?(@config_path)
27
+ end
28
+
29
+ # Load configuration from file
30
+ #
31
+ # @return [void]
32
+ # @raise [ConfigError] if file cannot be read or parsed
33
+ # @raise [ValidationError] if config structure is invalid
34
+ def load_config
35
+ data = YAML.load_file(@config_path)
36
+ validate_schema!(data)
37
+
38
+ @current_context = data['currentContext']
39
+ @contexts = data['contexts'].map do |ctx|
40
+ {
41
+ 'name' => ctx['name'],
42
+ 'dataDir' => expand_path(ctx['dataDir'])
43
+ }
44
+ end
45
+
46
+ validate_current_context!
47
+ rescue Psych::SyntaxError => e
48
+ raise ConfigError, "Invalid YAML in config file: #{e.message}"
49
+ rescue => e
50
+ raise ConfigError, "Failed to load config: #{e.message}"
51
+ end
52
+
53
+ # Save configuration to file (atomic write)
54
+ #
55
+ # @return [void]
56
+ # @raise [ConfigError] if file cannot be written
57
+ def save_config
58
+ data = {
59
+ 'apiVersion' => API_VERSION,
60
+ 'kind' => KIND,
61
+ 'currentContext' => @current_context,
62
+ 'contexts' => @contexts.map do |ctx|
63
+ {
64
+ 'name' => ctx['name'],
65
+ 'dataDir' => ctx['dataDir']
66
+ }
67
+ end
68
+ }
69
+
70
+ # Atomic write: write to temp file, then rename
71
+ dir = File.dirname(@config_path)
72
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
73
+
74
+ temp_file = "#{@config_path}.tmp.#{Process.pid}"
75
+ File.write(temp_file, YAML.dump(data))
76
+ File.rename(temp_file, @config_path)
77
+ rescue => e
78
+ File.delete(temp_file) if temp_file && File.exist?(temp_file)
79
+ raise ConfigError, "Failed to save config: #{e.message}"
80
+ end
81
+
82
+ # Get the data directory for a context
83
+ #
84
+ # @param context_name [String, nil] Context name (defaults to current context)
85
+ # @return [String] Path to data directory
86
+ # @raise [ConfigError] if context not found or no current context
87
+ def data_dir(context_name = nil)
88
+ name = context_name || @current_context
89
+ raise ConfigError, 'No active context selected' if name.nil?
90
+
91
+ context = find_context(name)
92
+ raise ConfigError, "Context '#{name}' not found" if context.nil?
93
+
94
+ context['dataDir']
95
+ end
96
+
97
+ # Add a new context
98
+ #
99
+ # @param name [String] Context name
100
+ # @param data_dir [String] Path to data directory
101
+ # @param activate [Boolean] Whether to activate this context immediately
102
+ # @return [void]
103
+ # @raise [ValidationError] if context name already exists
104
+ def add_context(name, data_dir, activate: true)
105
+ raise ValidationError, "Context '#{name}' already exists" if find_context(name)
106
+
107
+ @contexts << {
108
+ 'name' => name,
109
+ 'dataDir' => expand_path(data_dir)
110
+ }
111
+
112
+ @current_context = name if activate
113
+ end
114
+
115
+ # Remove a context
116
+ #
117
+ # @param name [String] Context name
118
+ # @return [void]
119
+ # @raise [ConfigError] if context not found
120
+ def delete_context(name)
121
+ context = find_context(name)
122
+ raise ConfigError, "Context '#{name}' not found" if context.nil?
123
+
124
+ @contexts.delete(context)
125
+
126
+ # If we deleted the active context, clear it
127
+ @current_context = nil if @current_context == name
128
+ end
129
+
130
+ # Switch to a different context
131
+ #
132
+ # @param name [String] Context name
133
+ # @return [void]
134
+ # @raise [ConfigError] if context not found
135
+ def use_context(name)
136
+ raise ConfigError, "Context '#{name}' not found" unless find_context(name)
137
+ @current_context = name
138
+ end
139
+
140
+ # List all contexts
141
+ #
142
+ # @return [Array<Hash>] Array of context hashes with 'name', 'dataDir', and 'active' keys
143
+ def list_contexts
144
+ @contexts.map do |ctx|
145
+ ctx.merge('active' => ctx['name'] == @current_context)
146
+ end
147
+ end
148
+
149
+ # Check if config file exists
150
+ #
151
+ # @return [Boolean]
152
+ def self.exists?(config_path: nil)
153
+ path = new(config_path: config_path).config_path
154
+ File.exist?(path)
155
+ end
156
+
157
+ private
158
+
159
+ # Resolve the config file path with precedence: param > $WHCONFIG > default
160
+ def resolve_config_path(config_path)
161
+ return expand_path(config_path) if config_path
162
+ return expand_path(ENV['WHCONFIG']) if ENV['WHCONFIG']
163
+ DEFAULT_CONFIG_PATH
164
+ end
165
+
166
+ # Expand path with ~ support
167
+ def expand_path(path)
168
+ File.expand_path(path)
169
+ end
170
+
171
+ # Find a context by name
172
+ def find_context(name)
173
+ @contexts.find { |ctx| ctx['name'] == name }
174
+ end
175
+
176
+ # Validate config schema
177
+ 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?
184
+
185
+ # Validate each context
186
+ data['contexts'].each_with_index do |ctx, index|
187
+ 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
+ end
191
+
192
+ # Check for duplicate context names
193
+ names = data['contexts'].map { |ctx| ctx['name'] }
194
+ duplicates = names.select { |name| names.count(name) > 1 }.uniq
195
+ raise ValidationError, "Duplicate context names: #{duplicates.join(', ')}" unless duplicates.empty?
196
+ end
197
+
198
+ # Validate that current context (if set) exists in contexts array
199
+ def validate_current_context!
200
+ return if @current_context.nil?
201
+ return if find_context(@current_context)
202
+
203
+ raise ValidationError, "Current context '#{@current_context}' not found in contexts"
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Walheim
4
+ class HandlerRegistry
5
+ class << self
6
+ def handlers
7
+ @handlers ||= {}
8
+ end
9
+
10
+ def register(kind:, plural:, singular:, handler_class:, aliases: [])
11
+ # Register plural (visible in listings)
12
+ handlers[plural] = {
13
+ handler: handler_class,
14
+ name: plural,
15
+ visible: true,
16
+ info: handler_class.kind_info
17
+ }
18
+
19
+ # Register singular (not visible)
20
+ handlers[singular] = {
21
+ handler: handler_class,
22
+ name: plural,
23
+ visible: false,
24
+ info: handler_class.kind_info
25
+ }
26
+
27
+ # Register aliases (not visible)
28
+ aliases.each do |alias_name|
29
+ handlers[alias_name] = {
30
+ handler: handler_class,
31
+ name: plural,
32
+ visible: false,
33
+ info: handler_class.kind_info
34
+ }
35
+ end
36
+ end
37
+
38
+ def get(kind)
39
+ handlers[kind]
40
+ end
41
+
42
+ def all_visible
43
+ handlers.values.select { |h| h[:visible] }.uniq { |h| h[:name] }.sort_by { |h| h[:name] }
44
+ end
45
+
46
+ def supports_operation?(kind, operation)
47
+ handler_info = get(kind)
48
+ return false unless handler_info
49
+
50
+ handler_class = handler_info[:handler]
51
+ handler_class.public_instance_methods.include?(operation.to_sym)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'resource'
4
+
5
+ module Walheim
6
+ # NamespacedResource represents resources that are scoped to a namespace
7
+ # Examples: Apps, Secrets, ConfigMaps
8
+ # These resources live under namespaces/{namespace}/{kind}/{name}/
9
+ class NamespacedResource < Resource
10
+ # CRUD operations for namespace-scoped resources
11
+
12
+ def apply(namespace:, name:, manifest_source: nil)
13
+ manifest_data = if manifest_source
14
+ File.read(manifest_source)
15
+ else
16
+ read_manifest_from_db(namespace, name)
17
+ end
18
+
19
+ if resource_exists?(namespace, name)
20
+ update(namespace: namespace, name: name, manifest: manifest_data)
21
+ else
22
+ create(namespace: namespace, name: name, manifest: manifest_data)
23
+ end
24
+ end
25
+
26
+ def create(namespace:, name:, manifest:)
27
+ # Validate manifest if validator exists
28
+ validate_manifest(manifest, namespace, name) if respond_to?(:validate_manifest, true)
29
+
30
+ # Create directory structure
31
+ ensure_resource_dir(namespace, name)
32
+
33
+ # Write manifest to DB
34
+ write_manifest(namespace, name, manifest)
35
+
36
+ puts "Created #{self.class.kind_info[:singular]} '#{name}' in namespace '#{namespace}'"
37
+
38
+ # POST-CREATE HOOK
39
+ trigger_hook(:post_create, namespace: namespace, name: name)
40
+ end
41
+
42
+ def update(namespace:, name:, manifest:)
43
+ # Validate manifest if validator exists
44
+ validate_manifest(manifest, namespace, name) if respond_to?(:validate_manifest, true)
45
+
46
+ # Write manifest to DB (overwrite)
47
+ write_manifest(namespace, name, manifest)
48
+
49
+ puts "Updated #{self.class.kind_info[:singular]} '#{name}' in namespace '#{namespace}'"
50
+
51
+ # POST-UPDATE HOOK
52
+ trigger_hook(:post_update, namespace: namespace, name: name)
53
+ end
54
+
55
+ def delete(namespace:, name:)
56
+ unless resource_exists?(namespace, name)
57
+ warn "Error: #{self.class.kind_info[:singular]} '#{name}' not found"
58
+ exit 1
59
+ end
60
+
61
+ # PRE-DELETE HOOK
62
+ trigger_hook(:pre_delete, namespace: namespace, name: name)
63
+
64
+ # Delete from DB
65
+ remove_resource_dir(namespace, name)
66
+
67
+ puts "Deleted #{self.class.kind_info[:singular]} '#{name}' from namespace '#{namespace}'"
68
+ end
69
+
70
+ def get(namespace:, name: nil)
71
+ if namespace.nil? && name.nil?
72
+ # List all resources across all namespaces
73
+ list_all_namespaces
74
+ elsif namespace && name.nil?
75
+ # List resources in a single namespace
76
+ list_single_namespace(namespace)
77
+ elsif namespace && name
78
+ # Get single resource
79
+ get_single_resource(namespace, name)
80
+ else
81
+ raise ArgumentError, 'namespace is required when name is specified'
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def get_single_resource(namespace, name)
88
+ unless resource_exists?(namespace, name)
89
+ warn "Error: #{self.class.kind_info[:singular]} '#{name}' not found in namespace '#{namespace}'"
90
+ exit 1
91
+ end
92
+
93
+ manifest_path = File.join(resource_dir(namespace, name), manifest_filename)
94
+ manifest_content = File.read(manifest_path)
95
+ manifest_data = YAML.load(manifest_content)
96
+
97
+ # Compute summary fields
98
+ summary = {}
99
+ self.class.summary_fields.each do |field_name, compute_fn|
100
+ summary[field_name] = compute_fn.call(manifest_data)
101
+ end
102
+
103
+ {
104
+ namespace: namespace,
105
+ name: name,
106
+ manifest: manifest_data,
107
+ summary: summary
108
+ }
109
+ end
110
+
111
+ # Path to resource directory: {data_dir}/namespaces/{namespace}/{kind_plural}/{name}/
112
+ def resource_dir(namespace, name)
113
+ File.join(@data_dir, 'namespaces', namespace, self.class.kind_info[:plural], name)
114
+ end
115
+
116
+ def resource_exists?(namespace, name)
117
+ manifest_path = File.join(resource_dir(namespace, name), manifest_filename)
118
+ File.exist?(manifest_path)
119
+ end
120
+
121
+ def ensure_resource_dir(namespace, name)
122
+ FileUtils.mkdir_p(resource_dir(namespace, name))
123
+ end
124
+
125
+ def write_manifest(namespace, name, manifest)
126
+ path = File.join(resource_dir(namespace, name), manifest_filename)
127
+ File.write(path, manifest)
128
+ end
129
+
130
+ def read_manifest_from_db(namespace, name)
131
+ path = File.join(resource_dir(namespace, name), manifest_filename)
132
+ unless File.exist?(path)
133
+ warn "Error: No manifest found at #{path}"
134
+ warn "Use 'whctl apply -f <file>' to create from a manifest file"
135
+ exit 1
136
+ end
137
+ File.read(path)
138
+ end
139
+
140
+ def remove_resource_dir(namespace, name)
141
+ FileUtils.rm_rf(resource_dir(namespace, name))
142
+ end
143
+
144
+ def list_single_namespace(namespace)
145
+ namespaces_dir = File.join(@data_dir, 'namespaces')
146
+ namespace_path = File.join(namespaces_dir, namespace)
147
+
148
+ unless Dir.exist?(namespace_path)
149
+ warn "Error: namespace '#{namespace}' not found"
150
+ exit 1
151
+ end
152
+
153
+ resources_path = File.join(namespace_path, self.class.kind_info[:plural])
154
+ return [] unless Dir.exist?(resources_path)
155
+
156
+ # Find all resource directories
157
+ resource_names = Dir.entries(resources_path)
158
+ .select { |entry| File.directory?(File.join(resources_path, entry)) && !entry.start_with?('.') }
159
+ .sort
160
+
161
+ # Return array of manifest hashes
162
+ resource_names.map do |name|
163
+ get_single_resource(namespace, name)
164
+ end
165
+ end
166
+
167
+ def list_all_namespaces
168
+ namespaces_dir = File.join(@data_dir, 'namespaces')
169
+ return [] unless Dir.exist?(namespaces_dir)
170
+
171
+ # Find all namespaces
172
+ 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')) }
175
+ .sort
176
+
177
+ # Collect all resources from all namespaces
178
+ all_resources = []
179
+ namespace_names.each do |namespace|
180
+ resources_path = File.join(namespaces_dir, namespace, self.class.kind_info[:plural])
181
+ next unless Dir.exist?(resources_path)
182
+
183
+ resource_names = Dir.entries(resources_path)
184
+ .select { |entry| File.directory?(File.join(resources_path, entry)) && !entry.start_with?('.') }
185
+ .sort
186
+
187
+ resource_names.each do |resource_name|
188
+ all_resources << get_single_resource(namespace, resource_name)
189
+ end
190
+ end
191
+
192
+ all_resources.sort_by { |resource| [resource[:namespace], resource[:name]] }
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require 'terminal-table'
6
+
7
+ module Walheim
8
+ # Base Resource class containing common functionality for all resource types
9
+ # Subclasses:
10
+ # - NamespacedResource: for namespace-scoped resources (apps, secrets, configmaps)
11
+ # - ClusterResource: for cluster-scoped resources (namespaces)
12
+ class Resource
13
+ attr_reader :data_dir
14
+
15
+ def initialize(data_dir: Dir.pwd)
16
+ @data_dir = data_dir
17
+ end
18
+
19
+ # Metadata - must be overridden by subclasses
20
+ def self.kind_info
21
+ raise NotImplementedError, 'Subclass must implement kind_info'
22
+ end
23
+
24
+ # Lifecycle hooks - can be overridden by subclasses
25
+ def self.hooks
26
+ {
27
+ post_create: nil, # Method name to call after create
28
+ post_update: nil, # Method name to call after update
29
+ pre_delete: nil # Method name to call before delete
30
+ }
31
+ end
32
+
33
+ # Summary fields for get output - can be overridden by subclasses
34
+ def self.summary_fields
35
+ {} # Default: no summary fields
36
+ end
37
+
38
+ # Operation metadata - defines how operations appear in help
39
+ # Subclasses can override to add custom operations
40
+ def self.operation_info
41
+ {
42
+ get: {
43
+ description: 'List or retrieve resources',
44
+ usage: [
45
+ "get #{kind_info[:plural]} -n {namespace}",
46
+ "get #{kind_info[:plural]} --all/-A",
47
+ "get #{kind_info[:singular]} {name} -n {namespace}"
48
+ ]
49
+ },
50
+ apply: {
51
+ description: 'Create or update a resource',
52
+ usage: ["apply #{kind_info[:singular]} {name} -n {namespace}"]
53
+ },
54
+ delete: {
55
+ description: 'Delete a resource',
56
+ usage: ["delete #{kind_info[:singular]} {name} -n {namespace}"]
57
+ }
58
+ }
59
+ end
60
+
61
+ protected
62
+
63
+ # Trigger lifecycle hooks
64
+ def trigger_hook(hook_type, **kwargs)
65
+ hook_method = self.class.hooks[hook_type]
66
+ return unless hook_method
67
+
68
+ send(hook_method, **kwargs)
69
+ end
70
+
71
+ # Default manifest filename - subclasses can override
72
+ def manifest_filename
73
+ "#{self.class.kind_info[:singular]}.yaml"
74
+ end
75
+ end
76
+ end