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,576 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../namespaced_resource'
|
|
4
|
+
require_relative '../sync'
|
|
5
|
+
require_relative '../handler_registry'
|
|
6
|
+
|
|
7
|
+
module Resources
|
|
8
|
+
class Apps < Walheim::NamespacedResource
|
|
9
|
+
def initialize(namespaces_dir: 'namespaces')
|
|
10
|
+
super
|
|
11
|
+
@syncer = Walheim::Sync.new(namespaces_dir: namespaces_dir)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.kind_info
|
|
15
|
+
{
|
|
16
|
+
plural: 'apps',
|
|
17
|
+
singular: 'app',
|
|
18
|
+
aliases: %w[application applications]
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.hooks
|
|
23
|
+
{
|
|
24
|
+
post_create: :start,
|
|
25
|
+
post_update: :start,
|
|
26
|
+
pre_delete: :stop
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.summary_fields
|
|
31
|
+
{
|
|
32
|
+
image: lambda { |manifest|
|
|
33
|
+
# Extract first service's image from compose spec
|
|
34
|
+
manifest.dig('spec', 'compose', 'services')&.values&.first&.dig('image') || 'N/A'
|
|
35
|
+
},
|
|
36
|
+
status: lambda { |_manifest|
|
|
37
|
+
# Could check if app is running, for now just show 'Configured'
|
|
38
|
+
'Configured'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.operation_info
|
|
44
|
+
# Start with base operations
|
|
45
|
+
ops = super
|
|
46
|
+
|
|
47
|
+
# Add apps-specific operations
|
|
48
|
+
ops.merge({
|
|
49
|
+
import: {
|
|
50
|
+
description: 'Import docker-compose as Walheim App',
|
|
51
|
+
usage: ['import app {name} -n {namespace} -f {docker-compose.yml}']
|
|
52
|
+
},
|
|
53
|
+
start: {
|
|
54
|
+
description: 'Compile, sync, and start app on host',
|
|
55
|
+
usage: ['start app {name} -n {namespace}']
|
|
56
|
+
},
|
|
57
|
+
pause: {
|
|
58
|
+
description: 'Stop app containers (keep files)',
|
|
59
|
+
usage: ['pause app {name} -n {namespace}']
|
|
60
|
+
},
|
|
61
|
+
stop: {
|
|
62
|
+
description: 'Stop app and remove files from host',
|
|
63
|
+
usage: ['stop app {name} -n {namespace}']
|
|
64
|
+
},
|
|
65
|
+
logs: {
|
|
66
|
+
description: 'View logs from remote containers',
|
|
67
|
+
usage: [
|
|
68
|
+
'logs app {name} -n {namespace}',
|
|
69
|
+
'logs app {name} -n {namespace} --follow',
|
|
70
|
+
'logs app {name} -n {namespace} --tail 100',
|
|
71
|
+
'logs app {name} -n {namespace} --timestamps'
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Import operation - converts docker-compose to Walheim App manifest
|
|
78
|
+
|
|
79
|
+
def import(namespace:, name:, compose_manifest:)
|
|
80
|
+
# Check if app already exists
|
|
81
|
+
if resource_exists?(namespace, name)
|
|
82
|
+
warn "Error: app '#{name}' already exists in namespace '#{namespace}'"
|
|
83
|
+
warn "Use 'whctl delete app #{name} -n #{namespace}' to remove it first"
|
|
84
|
+
exit 1
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Convert docker-compose to Walheim App manifest
|
|
88
|
+
app_manifest = {
|
|
89
|
+
'apiVersion' => 'walheim/v1alpha1',
|
|
90
|
+
'kind' => 'App',
|
|
91
|
+
'metadata' => {
|
|
92
|
+
'name' => name,
|
|
93
|
+
'namespace' => namespace
|
|
94
|
+
},
|
|
95
|
+
'spec' => {
|
|
96
|
+
'compose' => compose_manifest
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Convert to YAML string
|
|
101
|
+
manifest_yaml = YAML.dump(app_manifest)
|
|
102
|
+
|
|
103
|
+
# Call create with the converted manifest
|
|
104
|
+
create(namespace: namespace, name: name, manifest: manifest_yaml)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Lifecycle operations (controller hooks)
|
|
108
|
+
|
|
109
|
+
def start(namespace:, name:)
|
|
110
|
+
# Load app manifest (.app.yaml)
|
|
111
|
+
app_manifest = load_app_manifest(namespace, name)
|
|
112
|
+
|
|
113
|
+
# Generate final docker-compose.yml with injected envs
|
|
114
|
+
generate_compose_file(namespace, name, app_manifest)
|
|
115
|
+
|
|
116
|
+
# Sync files to remote
|
|
117
|
+
result = @syncer.sync(namespace: namespace, kind: 'apps', name: name)
|
|
118
|
+
|
|
119
|
+
# Run docker compose up
|
|
120
|
+
remote_host = result[:username] ? "#{result[:username]}@#{result[:hostname]}" : result[:hostname]
|
|
121
|
+
puts "Executing 'docker compose up -d' on #{remote_host} in #{result[:remote_dir]}"
|
|
122
|
+
ssh_command = "ssh #{remote_host} 'cd #{result[:remote_dir]} && docker compose up -d --remove-orphans'"
|
|
123
|
+
compose_result = system(ssh_command)
|
|
124
|
+
|
|
125
|
+
unless compose_result
|
|
126
|
+
warn 'Error: docker compose up failed'
|
|
127
|
+
exit 1
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
puts "Successfully started app '#{name}' in namespace '#{namespace}'"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def pause(namespace:, name:)
|
|
134
|
+
# Get namespace config
|
|
135
|
+
namespace_config = load_namespace_config(namespace)
|
|
136
|
+
username = namespace_config['username']
|
|
137
|
+
hostname = namespace_config['hostname']
|
|
138
|
+
|
|
139
|
+
remote_host = username ? "#{username}@#{hostname}" : hostname
|
|
140
|
+
remote_dir = "/data/walheim/apps/#{name}"
|
|
141
|
+
|
|
142
|
+
# Check if remote directory exists (for idempotent deletes)
|
|
143
|
+
check_command = "ssh #{remote_host} 'test -d #{remote_dir}'"
|
|
144
|
+
dir_exists = system(check_command)
|
|
145
|
+
|
|
146
|
+
unless dir_exists
|
|
147
|
+
puts "App '#{name}' not found on #{remote_host} (already stopped or never deployed)"
|
|
148
|
+
return
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
puts "Pausing app '#{name}' on #{remote_host}"
|
|
152
|
+
ssh_command = "ssh #{remote_host} 'cd #{remote_dir} && docker compose down'"
|
|
153
|
+
result = system(ssh_command)
|
|
154
|
+
|
|
155
|
+
unless result
|
|
156
|
+
warn 'Error: docker compose down failed'
|
|
157
|
+
exit 1
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
puts "Successfully paused app '#{name}' in namespace '#{namespace}'"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def stop(namespace:, name:)
|
|
164
|
+
# First pause (docker compose down)
|
|
165
|
+
pause(namespace: namespace, name: name)
|
|
166
|
+
|
|
167
|
+
# Then remove files from remote
|
|
168
|
+
namespace_config = load_namespace_config(namespace)
|
|
169
|
+
username = namespace_config['username']
|
|
170
|
+
hostname = namespace_config['hostname']
|
|
171
|
+
|
|
172
|
+
remote_host = username ? "#{username}@#{hostname}" : hostname
|
|
173
|
+
remote_dir = "/data/walheim/apps/#{name}"
|
|
174
|
+
|
|
175
|
+
puts "Removing files from #{remote_host}:#{remote_dir}"
|
|
176
|
+
ssh_command = "ssh #{remote_host} 'rm -rf #{remote_dir}'"
|
|
177
|
+
result = system(ssh_command)
|
|
178
|
+
|
|
179
|
+
unless result
|
|
180
|
+
warn 'Error: failed to remove remote files'
|
|
181
|
+
exit 1
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
puts "Successfully stopped app '#{name}' in namespace '#{namespace}'"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def logs(namespace:, name:, follow: false, tail: nil, timestamps: false)
|
|
188
|
+
# Get namespace config
|
|
189
|
+
namespace_config = load_namespace_config(namespace)
|
|
190
|
+
username = namespace_config['username']
|
|
191
|
+
hostname = namespace_config['hostname']
|
|
192
|
+
|
|
193
|
+
remote_host = username ? "#{username}@#{hostname}" : hostname
|
|
194
|
+
remote_dir = "/data/walheim/apps/#{name}"
|
|
195
|
+
|
|
196
|
+
# Check if remote directory exists
|
|
197
|
+
check_command = "ssh #{remote_host} 'test -d #{remote_dir}'"
|
|
198
|
+
dir_exists = system(check_command)
|
|
199
|
+
|
|
200
|
+
unless dir_exists
|
|
201
|
+
warn "Error: app '#{name}' not found on #{remote_host}"
|
|
202
|
+
exit 1
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Build docker compose logs command with options
|
|
206
|
+
logs_cmd = 'docker compose logs'
|
|
207
|
+
logs_cmd += ' --follow' if follow
|
|
208
|
+
logs_cmd += " --tail #{tail}" if tail
|
|
209
|
+
logs_cmd += ' --timestamps' if timestamps
|
|
210
|
+
|
|
211
|
+
# Execute logs command (this will stream output to terminal)
|
|
212
|
+
ssh_command = "ssh #{remote_host} 'cd #{remote_dir} && #{logs_cmd}'"
|
|
213
|
+
|
|
214
|
+
# Use exec instead of system to replace current process
|
|
215
|
+
# This allows proper signal handling (Ctrl+C) for --follow mode
|
|
216
|
+
exec(ssh_command)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private
|
|
220
|
+
|
|
221
|
+
def load_app_manifest(namespace, name)
|
|
222
|
+
app_dir = File.join(@namespaces_dir, namespace, 'apps', name)
|
|
223
|
+
app_yaml_path = File.join(app_dir, '.app.yaml')
|
|
224
|
+
|
|
225
|
+
unless File.exist?(app_yaml_path)
|
|
226
|
+
warn "Error: No app manifest found at #{app_yaml_path}"
|
|
227
|
+
warn 'Use \'whctl import\' to convert docker-compose.yml to Walheim App format'
|
|
228
|
+
exit 1
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
manifest = YAML.load_file(app_yaml_path)
|
|
232
|
+
|
|
233
|
+
# Validate structure
|
|
234
|
+
validate_k8s_manifest(manifest, namespace, name)
|
|
235
|
+
|
|
236
|
+
{
|
|
237
|
+
metadata: manifest['metadata'],
|
|
238
|
+
env_from: manifest['spec']['envFrom'] || [],
|
|
239
|
+
env: manifest['spec']['env'] || [],
|
|
240
|
+
compose: manifest['spec']['compose']
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def validate_k8s_manifest(manifest, namespace, name)
|
|
245
|
+
# Check required top-level fields
|
|
246
|
+
unless manifest['apiVersion'] == 'walheim/v1alpha1'
|
|
247
|
+
warn "Error: apiVersion must be 'walheim/v1alpha1', got '#{manifest['apiVersion']}'"
|
|
248
|
+
exit 1
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
unless manifest['kind'] == 'App'
|
|
252
|
+
warn "Error: kind must be 'App', got '#{manifest['kind']}'"
|
|
253
|
+
exit 1
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
unless manifest['metadata']
|
|
257
|
+
warn 'Error: metadata is required'
|
|
258
|
+
exit 1
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
unless manifest['spec']
|
|
262
|
+
warn 'Error: spec is required'
|
|
263
|
+
exit 1
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Check metadata fields
|
|
267
|
+
metadata_name = manifest['metadata']['name']
|
|
268
|
+
unless metadata_name == name
|
|
269
|
+
warn "Error: metadata.name '#{metadata_name}' must match directory name '#{name}'"
|
|
270
|
+
exit 1
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
metadata_namespace = manifest['metadata']['namespace']
|
|
274
|
+
unless metadata_namespace == namespace
|
|
275
|
+
warn "Error: metadata.namespace '#{metadata_namespace}' must match parent namespace '#{namespace}'"
|
|
276
|
+
exit 1
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Check spec.compose exists
|
|
280
|
+
return if manifest['spec']['compose']
|
|
281
|
+
|
|
282
|
+
warn 'Error: spec.compose is required'
|
|
283
|
+
exit 1
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def generate_compose_file(namespace, name, app_manifest)
|
|
287
|
+
app_dir = File.join(@namespaces_dir, namespace, 'apps', name)
|
|
288
|
+
compose_path = File.join(app_dir, 'docker-compose.yml')
|
|
289
|
+
|
|
290
|
+
# Start with base compose content
|
|
291
|
+
compose_content = deep_copy(app_manifest[:compose])
|
|
292
|
+
|
|
293
|
+
# Inject Walheim metadata labels (first, so it's clear these are managed)
|
|
294
|
+
compose_content = inject_walheim_labels(compose_content, namespace, name)
|
|
295
|
+
|
|
296
|
+
# Process envFrom and inject into compose (lower precedence)
|
|
297
|
+
unless app_manifest[:env_from].empty?
|
|
298
|
+
puts 'Processing envFrom...'
|
|
299
|
+
compose_content = inject_env_from(compose_content, app_manifest[:env_from], namespace)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Process env and inject into compose (higher precedence)
|
|
303
|
+
unless app_manifest[:env].empty?
|
|
304
|
+
puts 'Processing env...'
|
|
305
|
+
compose_content = inject_env(compose_content, app_manifest[:env])
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Write generated docker-compose.yml
|
|
309
|
+
File.write(compose_path, YAML.dump(compose_content))
|
|
310
|
+
puts "Generated docker-compose.yml at #{compose_path}"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def inject_walheim_labels(compose, namespace, name)
|
|
314
|
+
# Process each service and inject Walheim metadata labels
|
|
315
|
+
compose['services']&.each do |service_name, service_config|
|
|
316
|
+
service_config['labels'] ||= []
|
|
317
|
+
|
|
318
|
+
# Define Walheim metadata labels (stable labels only to avoid unnecessary restarts)
|
|
319
|
+
walheim_labels = [
|
|
320
|
+
'walheim.managed=true',
|
|
321
|
+
"walheim.namespace=#{namespace}",
|
|
322
|
+
"walheim.app=#{name}"
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
# Inject labels (handle both array and hash formats)
|
|
326
|
+
if service_config['labels'].is_a?(Array)
|
|
327
|
+
# Remove any existing walheim.* labels first to avoid duplicates
|
|
328
|
+
service_config['labels'].reject! { |label| label.to_s.start_with?('walheim.managed=', 'walheim.namespace=', 'walheim.app=') }
|
|
329
|
+
# Add new labels
|
|
330
|
+
service_config['labels'].concat(walheim_labels)
|
|
331
|
+
else
|
|
332
|
+
# Hash format
|
|
333
|
+
service_config['labels']['walheim.managed'] = 'true'
|
|
334
|
+
service_config['labels']['walheim.namespace'] = namespace
|
|
335
|
+
service_config['labels']['walheim.app'] = name
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
compose
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def inject_env_from(compose, env_from_list, namespace)
|
|
343
|
+
return compose if env_from_list.empty?
|
|
344
|
+
|
|
345
|
+
# Process each service
|
|
346
|
+
compose['services']&.each do |service_name, service_config|
|
|
347
|
+
service_config['environment'] ||= {}
|
|
348
|
+
service_config['labels'] ||= []
|
|
349
|
+
|
|
350
|
+
# Convert array-style to hash if needed
|
|
351
|
+
if service_config['environment'].is_a?(Array)
|
|
352
|
+
service_config['environment'] = array_env_to_hash(service_config['environment'])
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Track total injected for this service
|
|
356
|
+
total_injected = 0
|
|
357
|
+
|
|
358
|
+
# Inject from each envFrom source
|
|
359
|
+
env_from_list.each do |source|
|
|
360
|
+
# Check if this service should receive injections from this source
|
|
361
|
+
service_names = source['serviceNames']
|
|
362
|
+
next if service_names && !service_names.empty? && !service_names.include?(service_name)
|
|
363
|
+
|
|
364
|
+
if source['secretRef']
|
|
365
|
+
secret_name = source['secretRef']['name']
|
|
366
|
+
injected_keys = inject_from_secret(service_config['environment'], secret_name, namespace)
|
|
367
|
+
|
|
368
|
+
# Add tracking label if any keys were injected
|
|
369
|
+
if injected_keys.any?
|
|
370
|
+
add_tracking_label(service_config['labels'], "walheim.injected-env.secret.#{secret_name}", injected_keys)
|
|
371
|
+
puts " #{service_name}: Injected #{injected_keys.size} variable(s) from secret #{secret_name}: #{injected_keys.join(', ')}"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
total_injected += injected_keys.size
|
|
375
|
+
elsif source['configMapRef']
|
|
376
|
+
configmap_name = source['configMapRef']['name']
|
|
377
|
+
injected_keys = inject_from_configmap(service_config['environment'], configmap_name, namespace)
|
|
378
|
+
|
|
379
|
+
# Add tracking label if any keys were injected
|
|
380
|
+
if injected_keys.any?
|
|
381
|
+
add_tracking_label(service_config['labels'], "walheim.injected-env.configmap.#{configmap_name}",
|
|
382
|
+
injected_keys)
|
|
383
|
+
puts " #{service_name}: Injected #{injected_keys.size} variable(s) from configmap #{configmap_name}: #{injected_keys.join(', ')}"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
total_injected += injected_keys.size
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
compose
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def array_env_to_hash(env_array)
|
|
395
|
+
env_hash = {}
|
|
396
|
+
env_array.each do |env_line|
|
|
397
|
+
if env_line.include?('=')
|
|
398
|
+
key, value = env_line.split('=', 2)
|
|
399
|
+
env_hash[key] = value
|
|
400
|
+
elsif env_line.is_a?(Hash)
|
|
401
|
+
env_hash.merge!(env_line)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
env_hash
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def inject_from_secret(environment, secret_name, namespace)
|
|
408
|
+
secret_data = load_secret_data(namespace, secret_name)
|
|
409
|
+
injected_keys = []
|
|
410
|
+
|
|
411
|
+
secret_data.each do |key, value|
|
|
412
|
+
# Skip if key already exists (existing env vars take precedence)
|
|
413
|
+
next unless environment[key].nil?
|
|
414
|
+
|
|
415
|
+
environment[key] = value
|
|
416
|
+
injected_keys << key
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
injected_keys
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def inject_from_configmap(environment, configmap_name, namespace)
|
|
423
|
+
configmap_data = load_configmap_data(namespace, configmap_name)
|
|
424
|
+
injected_keys = []
|
|
425
|
+
|
|
426
|
+
configmap_data.each do |key, value|
|
|
427
|
+
# Skip if key already exists (existing env vars take precedence)
|
|
428
|
+
next unless environment[key].nil?
|
|
429
|
+
|
|
430
|
+
environment[key] = value
|
|
431
|
+
injected_keys << key
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
injected_keys
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def add_tracking_label(labels, label_key, injected_keys)
|
|
438
|
+
label_value = injected_keys.join(',')
|
|
439
|
+
|
|
440
|
+
# Add label (handle both array and hash formats)
|
|
441
|
+
if labels.is_a?(Array)
|
|
442
|
+
labels << "#{label_key}=#{label_value}"
|
|
443
|
+
else
|
|
444
|
+
labels[label_key] = label_value
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def inject_env(compose, env_list)
|
|
449
|
+
return compose if env_list.empty?
|
|
450
|
+
|
|
451
|
+
# Process each service
|
|
452
|
+
compose['services']&.each do |service_name, service_config|
|
|
453
|
+
service_config['environment'] ||= {}
|
|
454
|
+
service_config['labels'] ||= []
|
|
455
|
+
|
|
456
|
+
# Convert array-style to hash if needed
|
|
457
|
+
if service_config['environment'].is_a?(Array)
|
|
458
|
+
service_config['environment'] = array_env_to_hash(service_config['environment'])
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Track which keys were set by spec.env
|
|
462
|
+
injected_keys = []
|
|
463
|
+
|
|
464
|
+
# Process each env entry
|
|
465
|
+
env_list.each do |env_entry|
|
|
466
|
+
# Check if this service should receive this env var
|
|
467
|
+
service_names = env_entry['serviceNames']
|
|
468
|
+
next if service_names && !service_names.empty? && !service_names.include?(service_name)
|
|
469
|
+
|
|
470
|
+
var_name = env_entry['name']
|
|
471
|
+
var_value = env_entry['value']
|
|
472
|
+
|
|
473
|
+
# Perform variable substitution using current environment
|
|
474
|
+
substituted_value = substitute_variables(var_value, service_config['environment'])
|
|
475
|
+
|
|
476
|
+
# Always overwrite (highest precedence)
|
|
477
|
+
service_config['environment'][var_name] = substituted_value
|
|
478
|
+
injected_keys << var_name
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Add tracking label if any keys were set
|
|
482
|
+
if injected_keys.any?
|
|
483
|
+
add_tracking_label(service_config['labels'], 'walheim.injected-env.override', injected_keys)
|
|
484
|
+
puts " #{service_name}: Set #{injected_keys.size} variable(s) from spec.env: #{injected_keys.join(', ')}"
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
compose
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def substitute_variables(value, environment)
|
|
492
|
+
# Replace ${VAR_NAME} or $VAR_NAME with values from environment hash
|
|
493
|
+
# Supports uppercase letters, numbers, and underscores
|
|
494
|
+
value.to_s.gsub(/\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/) do
|
|
495
|
+
var_name = Regexp.last_match(1) || Regexp.last_match(2)
|
|
496
|
+
environment[var_name] || "${#{var_name}}" # Keep original if not found
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def load_configmap_data(namespace, configmap_name)
|
|
501
|
+
require 'base64'
|
|
502
|
+
|
|
503
|
+
configmap_path = File.join(@namespaces_dir, namespace, 'configmaps', configmap_name, 'configmap.yaml')
|
|
504
|
+
|
|
505
|
+
unless File.exist?(configmap_path)
|
|
506
|
+
warn "Error: configmap '#{configmap_name}' not found at #{configmap_path}"
|
|
507
|
+
exit 1
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
configmap = YAML.load_file(configmap_path)
|
|
511
|
+
|
|
512
|
+
# Extract data (plaintext only for configmaps)
|
|
513
|
+
configmap['data'] || {}
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def deep_copy(obj)
|
|
517
|
+
Marshal.load(Marshal.dump(obj))
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def manifest_filename
|
|
521
|
+
'.app.yaml'
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def validate_manifest(manifest, namespace, name)
|
|
525
|
+
manifest_hash = YAML.load(manifest)
|
|
526
|
+
validate_k8s_manifest(manifest_hash, namespace, name) if manifest_hash['kind'] == 'App'
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def load_secret_data(namespace, secret_name)
|
|
530
|
+
require 'base64'
|
|
531
|
+
|
|
532
|
+
secret_path = File.join(@namespaces_dir, namespace, 'secrets', secret_name, 'secret.yaml')
|
|
533
|
+
|
|
534
|
+
unless File.exist?(secret_path)
|
|
535
|
+
warn "Error: secret '#{secret_name}' not found at #{secret_path}"
|
|
536
|
+
exit 1
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
secret = YAML.load_file(secret_path)
|
|
540
|
+
|
|
541
|
+
# Extract data (base64-encoded) and stringData (plaintext)
|
|
542
|
+
data = secret['data'] || {}
|
|
543
|
+
string_data = secret['stringData'] || {}
|
|
544
|
+
|
|
545
|
+
# Decode base64 data
|
|
546
|
+
decoded_data = {}
|
|
547
|
+
data.each do |key, value|
|
|
548
|
+
decoded_data[key] = Base64.decode64(value.to_s)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Merge decoded data and stringData (stringData takes precedence)
|
|
552
|
+
decoded_data.merge(string_data)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def load_namespace_config(namespace)
|
|
556
|
+
config_path = File.join(@namespaces_dir, namespace, '.namespace.yaml')
|
|
557
|
+
|
|
558
|
+
unless File.exist?(config_path)
|
|
559
|
+
warn "Error: namespace config '#{config_path}' not found"
|
|
560
|
+
exit 1
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
YAML.load_file(config_path)
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# Register handler
|
|
569
|
+
info = Resources::Apps.kind_info
|
|
570
|
+
Walheim::HandlerRegistry.register(
|
|
571
|
+
kind: info[:plural],
|
|
572
|
+
plural: info[:plural],
|
|
573
|
+
singular: info[:singular],
|
|
574
|
+
handler_class: Resources::Apps,
|
|
575
|
+
aliases: info[:aliases] || []
|
|
576
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../namespaced_resource'
|
|
4
|
+
require_relative '../handler_registry'
|
|
5
|
+
|
|
6
|
+
module Resources
|
|
7
|
+
class ConfigMaps < Walheim::NamespacedResource
|
|
8
|
+
def self.kind_info
|
|
9
|
+
{
|
|
10
|
+
plural: 'configmaps',
|
|
11
|
+
singular: 'configmap',
|
|
12
|
+
aliases: ['cm'] # Abbreviation
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.hooks
|
|
17
|
+
{
|
|
18
|
+
post_create: nil,
|
|
19
|
+
post_update: nil,
|
|
20
|
+
pre_delete: nil
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.summary_fields
|
|
25
|
+
{
|
|
26
|
+
keys: lambda { |manifest|
|
|
27
|
+
(manifest['data'] || {}).keys.join(', ')
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def manifest_filename
|
|
35
|
+
'configmap.yaml'
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Register handler
|
|
41
|
+
info = Resources::ConfigMaps.kind_info
|
|
42
|
+
Walheim::HandlerRegistry.register(
|
|
43
|
+
kind: info[:plural],
|
|
44
|
+
plural: info[:plural],
|
|
45
|
+
singular: info[:singular],
|
|
46
|
+
handler_class: Resources::ConfigMaps,
|
|
47
|
+
aliases: info[:aliases] || []
|
|
48
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'terminal-table'
|
|
5
|
+
require_relative '../cluster_resource'
|
|
6
|
+
require_relative '../handler_registry'
|
|
7
|
+
|
|
8
|
+
module Resources
|
|
9
|
+
class Namespaces < Walheim::ClusterResource
|
|
10
|
+
def self.kind_info
|
|
11
|
+
{
|
|
12
|
+
plural: 'namespaces',
|
|
13
|
+
singular: 'namespace',
|
|
14
|
+
aliases: ['ns']
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.summary_fields
|
|
19
|
+
{
|
|
20
|
+
username: ->(manifest) { manifest['username'] || 'N/A' },
|
|
21
|
+
hostname: ->(manifest) { manifest['hostname'] || 'N/A' }
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def manifest_filename
|
|
28
|
+
'.namespace.yaml'
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Register handler
|
|
34
|
+
info = Resources::Namespaces.kind_info
|
|
35
|
+
Walheim::HandlerRegistry.register(
|
|
36
|
+
kind: info[:plural],
|
|
37
|
+
plural: info[:plural],
|
|
38
|
+
singular: info[:singular],
|
|
39
|
+
handler_class: Resources::Namespaces,
|
|
40
|
+
aliases: info[:aliases] || []
|
|
41
|
+
)
|