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.
- checksums.yaml +7 -0
- data/README.md +321 -0
- data/bin/whctl +703 -0
- data/lib/walheim/cluster_resource.rb +166 -0
- data/lib/walheim/config.rb +206 -0
- data/lib/walheim/handler_registry.rb +55 -0
- data/lib/walheim/namespaced_resource.rb +195 -0
- data/lib/walheim/resource.rb +76 -0
- data/lib/walheim/resources/apps.rb +576 -0
- data/lib/walheim/resources/configmaps.rb +48 -0
- data/lib/walheim/resources/namespaces.rb +41 -0
- data/lib/walheim/resources/secrets.rb +50 -0
- data/lib/walheim/sync.rb +60 -0
- data/lib/walheim/version.rb +5 -0
- data/lib/walheim.rb +19 -0
- metadata +105 -0
|
@@ -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
|