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,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
+ )