walheim 0.2.1 → 0.3.0
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 +4 -4
- data/README.md +2 -0
- data/lib/walheim/cli/base_command.rb +85 -81
- data/lib/walheim/cli.rb +44 -0
- data/lib/walheim/namespaced_resource.rb +12 -1
- data/lib/walheim/resource.rb +19 -3
- data/lib/walheim/resources/apps.rb +376 -6
- data/lib/walheim/resources/namespaces.rb +245 -2
- data/lib/walheim/version.rb +1 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c859436cfcc9582a4a875cf547855472d592b37e391f58abadd0003943272d1d
|
|
4
|
+
data.tar.gz: 15b5ad58f1c94475882e31c41c203f6a8446a63629ed7543957fc3244282be2b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b4c2b0a0ff5d4e6ca42d2ac2159c85689fc7faca2f633061d11b8d3cb8a2cf3bf2d4e9d07c35ca6d2abfb9768fbdcaacd49a0ccb673ba0b0cab55da3ec80c5ba
|
|
7
|
+
data.tar.gz: 2c1c9369e7372cf6c21d1771870e805e43f10a8a082dc886d4a18e6f7ad70bd272e50bb3463c6cafeb8a37acf5a351b0d7efe5ba12b070c6ae45092d92e4d375
|
data/README.md
CHANGED
|
@@ -16,22 +16,30 @@ module Walheim
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# 2. Check operation support
|
|
19
|
+
handler_class = handler_info[:handler]
|
|
19
20
|
unless Walheim::HandlerRegistry.supports_operation?(kind, operation)
|
|
20
21
|
warn "Error: #{operation} not supported for #{kind}"
|
|
21
22
|
exit 1
|
|
22
23
|
end
|
|
23
24
|
|
|
24
|
-
# 3.
|
|
25
|
+
# 3. Get operation metadata
|
|
26
|
+
op_metadata = handler_class.operation_info[operation]
|
|
27
|
+
unless op_metadata
|
|
28
|
+
warn "Error: operation metadata missing for #{operation}"
|
|
29
|
+
exit 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# 4. Resolve data directory from context
|
|
25
33
|
data_dir = resolve_data_dir(options, parent_options)
|
|
26
34
|
|
|
27
|
-
#
|
|
28
|
-
handler =
|
|
35
|
+
# 5. Initialize handler
|
|
36
|
+
handler = handler_class.new(data_dir: data_dir)
|
|
29
37
|
|
|
30
|
-
#
|
|
31
|
-
|
|
38
|
+
# 6. Validate namespace requirements
|
|
39
|
+
validate_namespace_requirements!(operation, kind, name, options, op_metadata) if handler.is_a?(Walheim::NamespacedResource)
|
|
32
40
|
|
|
33
|
-
#
|
|
34
|
-
|
|
41
|
+
# 7. Dispatch to handler using metadata
|
|
42
|
+
dispatch_operation(handler, operation, name, options, handler_info, op_metadata)
|
|
35
43
|
end
|
|
36
44
|
|
|
37
45
|
def self.resolve_data_dir(options, parent_options)
|
|
@@ -70,101 +78,97 @@ module Walheim
|
|
|
70
78
|
end
|
|
71
79
|
end
|
|
72
80
|
|
|
73
|
-
def self.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return unless requires_namespace.include?(operation)
|
|
81
|
+
def self.validate_namespace_requirements!(operation, kind, _name, options, op_metadata)
|
|
82
|
+
dispatch_meta = op_metadata[:dispatch] || {}
|
|
83
|
+
namespace_handling = dispatch_meta[:namespace_handling]
|
|
77
84
|
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
case namespace_handling
|
|
86
|
+
when :optional_with_all
|
|
87
|
+
# Operations like get can use --all or -n
|
|
80
88
|
return if options[:all] || options[:namespace]
|
|
81
89
|
|
|
82
90
|
warn "Error: either -n {namespace} or --all/-A flag is required"
|
|
83
|
-
warn "Usage: whctl
|
|
84
|
-
warn "Usage: whctl
|
|
91
|
+
warn "Usage: whctl #{operation} #{kind} -n {namespace}"
|
|
92
|
+
warn "Usage: whctl #{operation} #{kind} --all"
|
|
85
93
|
exit 1
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return if options[:namespace]
|
|
94
|
+
when :required
|
|
95
|
+
# Operations require namespace
|
|
96
|
+
return if options[:namespace]
|
|
90
97
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
case operation
|
|
98
|
-
when :get
|
|
99
|
-
dispatch_get(handler, name, options, handler_info)
|
|
100
|
-
when :apply
|
|
101
|
-
dispatch_apply(handler, name, options, handler_info)
|
|
102
|
-
when :delete
|
|
103
|
-
handler.delete(namespace: options[:namespace], name: name)
|
|
104
|
-
when :create
|
|
105
|
-
# Special case: create namespace
|
|
106
|
-
handler.create(name: name, username: options[:username], hostname: options[:hostname])
|
|
107
|
-
when :import
|
|
108
|
-
# Special case: import app
|
|
109
|
-
compose_manifest = Walheim::Helpers.read_yaml_input(options[:file])
|
|
110
|
-
handler.import(namespace: options[:namespace], name: name, compose_manifest: compose_manifest)
|
|
111
|
-
when :start, :pause, :stop
|
|
112
|
-
handler.send(operation, namespace: options[:namespace], name: name)
|
|
113
|
-
when :logs
|
|
114
|
-
log_opts = {}
|
|
115
|
-
log_opts[:follow] = options[:follow] if options[:follow]
|
|
116
|
-
log_opts[:tail] = options[:tail] if options[:tail]
|
|
117
|
-
log_opts[:timestamps] = options[:timestamps] if options[:timestamps]
|
|
118
|
-
handler.logs(namespace: options[:namespace], name: name, **log_opts)
|
|
98
|
+
warn "Error: -n {namespace} is required"
|
|
99
|
+
warn "Usage: whctl #{operation} #{kind} {name} -n {namespace}"
|
|
100
|
+
exit 1
|
|
101
|
+
when nil
|
|
102
|
+
# No namespace validation needed
|
|
103
|
+
nil
|
|
119
104
|
else
|
|
120
|
-
warn "Error:
|
|
105
|
+
warn "Error: unknown namespace_handling: #{namespace_handling}"
|
|
121
106
|
exit 1
|
|
122
107
|
end
|
|
123
108
|
end
|
|
124
109
|
|
|
125
|
-
def self.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
110
|
+
def self.dispatch_operation(handler, operation, name, options, handler_info, op_metadata)
|
|
111
|
+
dispatch_meta = op_metadata[:dispatch] || {}
|
|
112
|
+
method_name = dispatch_meta[:method] || operation
|
|
113
|
+
|
|
114
|
+
# Build method parameters
|
|
115
|
+
params = build_method_params(name, options, dispatch_meta)
|
|
116
|
+
|
|
117
|
+
# Call handler method
|
|
118
|
+
result = handler.send(method_name, **params)
|
|
119
|
+
|
|
120
|
+
# Handle output formatting
|
|
121
|
+
handle_output(result, handler, handler_info, options, dispatch_meta)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.build_method_params(name, options, dispatch_meta)
|
|
125
|
+
params = {}
|
|
126
|
+
|
|
127
|
+
# Add positional params (like :name)
|
|
128
|
+
positional_params = dispatch_meta[:params] || []
|
|
129
|
+
positional_params.each do |param_name|
|
|
130
|
+
case param_name
|
|
131
|
+
when :name
|
|
132
|
+
params[:name] = name
|
|
132
133
|
else
|
|
133
|
-
|
|
134
|
+
params[param_name] = options[param_name]
|
|
134
135
|
end
|
|
135
|
-
Walheim::Helpers.print_resources_table(result, options[:all], handler_info[:name])
|
|
136
136
|
end
|
|
137
|
-
end
|
|
138
137
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
138
|
+
# Add named params from options
|
|
139
|
+
named_params = dispatch_meta[:named_params] || {}
|
|
140
|
+
named_params.each do |param_name, option_key|
|
|
141
|
+
option_value = options[option_key]
|
|
143
142
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
# Handle file readers
|
|
144
|
+
if dispatch_meta[:file_reader] == param_name && option_value
|
|
145
|
+
option_value = Walheim::Helpers.read_yaml_input(option_value)
|
|
146
|
+
end
|
|
147
147
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
# Always include declared parameters (handlers expect them as keyword args)
|
|
149
|
+
params[param_name] = option_value
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
params
|
|
153
|
+
end
|
|
152
154
|
|
|
153
|
-
|
|
155
|
+
def self.handle_output(result, handler, handler_info, options, dispatch_meta)
|
|
156
|
+
output_type = dispatch_meta[:output]
|
|
157
|
+
|
|
158
|
+
case output_type
|
|
159
|
+
when :table
|
|
160
|
+
# Table output for get operations
|
|
161
|
+
if handler.is_a?(Walheim::ClusterResource)
|
|
162
|
+
Walheim::Helpers.print_cluster_resources_table(result, handler_info[:name])
|
|
154
163
|
else
|
|
155
|
-
|
|
156
|
-
name = manifest_data["metadata"]["name"]
|
|
157
|
-
unless name
|
|
158
|
-
warn "Error: Manifest must contain metadata.name"
|
|
159
|
-
exit 1
|
|
160
|
-
end
|
|
161
|
-
handler.apply(name: name, manifest_source: options[:file])
|
|
164
|
+
Walheim::Helpers.print_resources_table(result, options[:all], handler_info[:name])
|
|
162
165
|
end
|
|
163
|
-
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
+
when nil
|
|
167
|
+
# No output handling needed (handler prints directly)
|
|
168
|
+
nil
|
|
166
169
|
else
|
|
167
|
-
|
|
170
|
+
warn "Error: unknown output type: #{output_type}"
|
|
171
|
+
exit 1
|
|
168
172
|
end
|
|
169
173
|
end
|
|
170
174
|
end
|
data/lib/walheim/cli.rb
CHANGED
|
@@ -7,6 +7,11 @@ require_relative "cli/resource_command"
|
|
|
7
7
|
|
|
8
8
|
module Walheim
|
|
9
9
|
class CLI < Thor
|
|
10
|
+
# Exit with non-zero status on errors
|
|
11
|
+
def self.exit_on_failure?
|
|
12
|
+
true
|
|
13
|
+
end
|
|
14
|
+
|
|
10
15
|
# Global flags
|
|
11
16
|
class_option :context,
|
|
12
17
|
type: :string,
|
|
@@ -34,6 +39,45 @@ module Walheim
|
|
|
34
39
|
puts "whctl version #{Walheim::VERSION}"
|
|
35
40
|
end
|
|
36
41
|
|
|
42
|
+
# Exec command - custom implementation to support variadic arguments
|
|
43
|
+
desc "exec app NAME [--] COMMAND...", "Execute command in app container"
|
|
44
|
+
method_option :namespace,
|
|
45
|
+
type: :string,
|
|
46
|
+
aliases: [ :n ],
|
|
47
|
+
desc: "Target namespace",
|
|
48
|
+
required: true
|
|
49
|
+
method_option :service,
|
|
50
|
+
type: :string,
|
|
51
|
+
aliases: [ :s ],
|
|
52
|
+
desc: "Target service (defaults to first)"
|
|
53
|
+
method_option :interactive,
|
|
54
|
+
type: :boolean,
|
|
55
|
+
aliases: [ :it ],
|
|
56
|
+
desc: "Allocate pseudo-TTY and keep stdin open",
|
|
57
|
+
default: false
|
|
58
|
+
def exec(kind, name, *command)
|
|
59
|
+
# Validate kind is 'app' or 'apps'
|
|
60
|
+
unless %w[app apps].include?(kind.downcase)
|
|
61
|
+
warn "Error: exec only supports 'app' kind, got '#{kind}'"
|
|
62
|
+
exit 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Resolve data directory from context
|
|
66
|
+
data_dir = BaseCommand.send(:resolve_data_dir, options, self.class.class_options.transform_keys(&:to_sym).transform_values { |v| options[v.name] rescue nil })
|
|
67
|
+
|
|
68
|
+
# Initialize Apps handler
|
|
69
|
+
handler = Resources::Apps.new(data_dir: data_dir)
|
|
70
|
+
|
|
71
|
+
# Call exec_command method
|
|
72
|
+
handler.exec_command(
|
|
73
|
+
namespace: options[:namespace],
|
|
74
|
+
name: name,
|
|
75
|
+
service: options[:service],
|
|
76
|
+
interactive: options[:interactive],
|
|
77
|
+
command: command
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
37
81
|
# Override help to maintain kubectl-style help
|
|
38
82
|
def self.help(shell, subcommand = false)
|
|
39
83
|
list = printable_commands(true, subcommand)
|
|
@@ -7,7 +7,7 @@ module Walheim
|
|
|
7
7
|
# Examples: Apps, Secrets, ConfigMaps
|
|
8
8
|
# These resources live under namespaces/{namespace}/{kind}/{name}/
|
|
9
9
|
class NamespacedResource < Resource
|
|
10
|
-
# Override operation_info to add namespace flags
|
|
10
|
+
# Override operation_info to add namespace flags and dispatch params
|
|
11
11
|
def self.operation_info
|
|
12
12
|
ops = super
|
|
13
13
|
|
|
@@ -31,6 +31,17 @@ module Walheim
|
|
|
31
31
|
ops[:apply][:options].merge!(namespace: namespace_options[:namespace])
|
|
32
32
|
ops[:delete][:options].merge!(namespace: namespace_options[:namespace])
|
|
33
33
|
|
|
34
|
+
# Add namespace parameter to dispatch
|
|
35
|
+
ops[:get][:dispatch][:named_params] = { namespace: :namespace }
|
|
36
|
+
ops[:get][:dispatch][:namespace_handling] = :optional_with_all
|
|
37
|
+
|
|
38
|
+
ops[:apply][:dispatch][:named_params] ||= {}
|
|
39
|
+
ops[:apply][:dispatch][:named_params][:namespace] = :namespace
|
|
40
|
+
ops[:apply][:dispatch][:namespace_handling] = :required
|
|
41
|
+
|
|
42
|
+
ops[:delete][:dispatch][:named_params] = { namespace: :namespace }
|
|
43
|
+
ops[:delete][:dispatch][:namespace_handling] = :required
|
|
44
|
+
|
|
34
45
|
ops
|
|
35
46
|
end
|
|
36
47
|
|
data/lib/walheim/resource.rb
CHANGED
|
@@ -35,7 +35,7 @@ module Walheim
|
|
|
35
35
|
{} # Default: no summary fields
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
# Operation metadata - defines how operations appear in help
|
|
38
|
+
# Operation metadata - defines how operations appear in help and how they're dispatched
|
|
39
39
|
# Subclasses can override to add custom operations
|
|
40
40
|
def self.operation_info
|
|
41
41
|
{
|
|
@@ -46,19 +46,35 @@ module Walheim
|
|
|
46
46
|
"get #{kind_info[:plural]} --all/-A",
|
|
47
47
|
"get #{kind_info[:singular]} {name} -n {namespace}"
|
|
48
48
|
],
|
|
49
|
-
options: {} # Subclasses will override
|
|
49
|
+
options: {}, # Subclasses will override
|
|
50
|
+
dispatch: {
|
|
51
|
+
method: :get,
|
|
52
|
+
params: [ :name ],
|
|
53
|
+
output: :table
|
|
54
|
+
}
|
|
50
55
|
},
|
|
51
56
|
apply: {
|
|
52
57
|
description: "Create or update a resource",
|
|
53
58
|
usage: [ "apply #{kind_info[:singular]} {name} -n {namespace}" ],
|
|
54
59
|
options: {
|
|
55
60
|
file: { type: :string, aliases: [ :f ], desc: "Manifest file (use - for stdin)" }
|
|
61
|
+
},
|
|
62
|
+
dispatch: {
|
|
63
|
+
method: :apply,
|
|
64
|
+
params: [ :name ],
|
|
65
|
+
named_params: {
|
|
66
|
+
manifest_source: :file
|
|
67
|
+
}
|
|
56
68
|
}
|
|
57
69
|
},
|
|
58
70
|
delete: {
|
|
59
71
|
description: "Delete a resource",
|
|
60
72
|
usage: [ "delete #{kind_info[:singular]} {name} -n {namespace}" ],
|
|
61
|
-
options: {} # Subclasses will override
|
|
73
|
+
options: {}, # Subclasses will override
|
|
74
|
+
dispatch: {
|
|
75
|
+
method: :delete,
|
|
76
|
+
params: [ :name ]
|
|
77
|
+
}
|
|
62
78
|
}
|
|
63
79
|
}
|
|
64
80
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "parallel"
|
|
3
5
|
require_relative "../namespaced_resource"
|
|
4
6
|
require_relative "../sync"
|
|
5
7
|
require_relative "../handler_registry"
|
|
@@ -35,8 +37,14 @@ module Resources
|
|
|
35
37
|
# Extract first service's image from compose spec
|
|
36
38
|
manifest.dig("spec", "compose", "services")&.values&.first&.dig("image") || "N/A"
|
|
37
39
|
},
|
|
38
|
-
|
|
39
|
-
#
|
|
40
|
+
ready: lambda { |manifest|
|
|
41
|
+
# Actual ready count is fetched and cached by get() method
|
|
42
|
+
# This lambda is a placeholder
|
|
43
|
+
"-"
|
|
44
|
+
},
|
|
45
|
+
status: lambda { |manifest|
|
|
46
|
+
# Actual status is fetched and cached by get() method
|
|
47
|
+
# This lambda reads from the cache
|
|
40
48
|
"Configured"
|
|
41
49
|
}
|
|
42
50
|
}
|
|
@@ -46,7 +54,7 @@ module Resources
|
|
|
46
54
|
# Start with base operations
|
|
47
55
|
ops = super
|
|
48
56
|
|
|
49
|
-
namespace_opt = { type: :string, aliases: [ :n ], desc: "Target namespace"
|
|
57
|
+
namespace_opt = { type: :string, aliases: [ :n ], desc: "Target namespace" }
|
|
50
58
|
|
|
51
59
|
# Add apps-specific operations
|
|
52
60
|
ops.merge({
|
|
@@ -56,22 +64,50 @@ module Resources
|
|
|
56
64
|
options: {
|
|
57
65
|
namespace: namespace_opt,
|
|
58
66
|
file: { type: :string, aliases: [ :f ], desc: "docker-compose.yml path", required: true }
|
|
67
|
+
},
|
|
68
|
+
dispatch: {
|
|
69
|
+
method: :import,
|
|
70
|
+
params: [ :name ],
|
|
71
|
+
named_params: {
|
|
72
|
+
namespace: :namespace,
|
|
73
|
+
compose_manifest: :file
|
|
74
|
+
},
|
|
75
|
+
namespace_handling: :required,
|
|
76
|
+
file_reader: :compose_manifest # Read YAML from file option
|
|
59
77
|
}
|
|
60
78
|
},
|
|
61
79
|
start: {
|
|
62
80
|
description: "Compile, sync, and start app on host",
|
|
63
81
|
usage: [ "start app {name} -n {namespace}" ],
|
|
64
|
-
options: { namespace: namespace_opt }
|
|
82
|
+
options: { namespace: namespace_opt },
|
|
83
|
+
dispatch: {
|
|
84
|
+
method: :start,
|
|
85
|
+
params: [ :name ],
|
|
86
|
+
named_params: { namespace: :namespace },
|
|
87
|
+
namespace_handling: :required
|
|
88
|
+
}
|
|
65
89
|
},
|
|
66
90
|
pause: {
|
|
67
91
|
description: "Stop app containers (keep files)",
|
|
68
92
|
usage: [ "pause app {name} -n {namespace}" ],
|
|
69
|
-
options: { namespace: namespace_opt }
|
|
93
|
+
options: { namespace: namespace_opt },
|
|
94
|
+
dispatch: {
|
|
95
|
+
method: :pause,
|
|
96
|
+
params: [ :name ],
|
|
97
|
+
named_params: { namespace: :namespace },
|
|
98
|
+
namespace_handling: :required
|
|
99
|
+
}
|
|
70
100
|
},
|
|
71
101
|
stop: {
|
|
72
102
|
description: "Stop app and remove files from host",
|
|
73
103
|
usage: [ "stop app {name} -n {namespace}" ],
|
|
74
|
-
options: { namespace: namespace_opt }
|
|
104
|
+
options: { namespace: namespace_opt },
|
|
105
|
+
dispatch: {
|
|
106
|
+
method: :stop,
|
|
107
|
+
params: [ :name ],
|
|
108
|
+
named_params: { namespace: :namespace },
|
|
109
|
+
namespace_handling: :required
|
|
110
|
+
}
|
|
75
111
|
},
|
|
76
112
|
logs: {
|
|
77
113
|
description: "View logs from remote containers",
|
|
@@ -86,6 +122,39 @@ module Resources
|
|
|
86
122
|
follow: { type: :boolean, desc: "Follow log output" },
|
|
87
123
|
tail: { type: :numeric, desc: "Number of lines from end" },
|
|
88
124
|
timestamps: { type: :boolean, desc: "Show timestamps" }
|
|
125
|
+
},
|
|
126
|
+
dispatch: {
|
|
127
|
+
method: :logs,
|
|
128
|
+
params: [ :name ],
|
|
129
|
+
named_params: {
|
|
130
|
+
namespace: :namespace,
|
|
131
|
+
follow: :follow,
|
|
132
|
+
tail: :tail,
|
|
133
|
+
timestamps: :timestamps
|
|
134
|
+
},
|
|
135
|
+
namespace_handling: :required
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
pull: {
|
|
139
|
+
description: "Pull latest images without restarting",
|
|
140
|
+
usage: [ "pull app {name} -n {namespace}" ],
|
|
141
|
+
options: { namespace: namespace_opt },
|
|
142
|
+
dispatch: {
|
|
143
|
+
method: :pull,
|
|
144
|
+
params: [ :name ],
|
|
145
|
+
named_params: { namespace: :namespace },
|
|
146
|
+
namespace_handling: :required
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
describe: {
|
|
150
|
+
description: "Show running status of app containers",
|
|
151
|
+
usage: [ "describe app {name} -n {namespace}" ],
|
|
152
|
+
options: { namespace: namespace_opt },
|
|
153
|
+
dispatch: {
|
|
154
|
+
method: :describe,
|
|
155
|
+
params: [ :name ],
|
|
156
|
+
named_params: { namespace: :namespace },
|
|
157
|
+
namespace_handling: :required
|
|
89
158
|
}
|
|
90
159
|
}
|
|
91
160
|
})
|
|
@@ -233,6 +302,307 @@ module Resources
|
|
|
233
302
|
exec(ssh_command)
|
|
234
303
|
end
|
|
235
304
|
|
|
305
|
+
def pull(namespace:, name:)
|
|
306
|
+
# Get namespace config
|
|
307
|
+
namespace_config = load_namespace_config(namespace)
|
|
308
|
+
username = namespace_config["username"]
|
|
309
|
+
hostname = namespace_config["hostname"]
|
|
310
|
+
|
|
311
|
+
remote_host = username ? "#{username}@#{hostname}" : hostname
|
|
312
|
+
remote_dir = "/data/walheim/apps/#{name}"
|
|
313
|
+
|
|
314
|
+
# Check if remote directory exists
|
|
315
|
+
check_command = "ssh #{remote_host} 'test -d #{remote_dir}'"
|
|
316
|
+
dir_exists = system(check_command)
|
|
317
|
+
|
|
318
|
+
unless dir_exists
|
|
319
|
+
warn "Error: app '#{name}' not found on #{remote_host}"
|
|
320
|
+
warn "Deploy the app first using 'whctl apply app #{name} -n #{namespace}'"
|
|
321
|
+
exit 1
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Pull latest images
|
|
325
|
+
puts "Pulling latest images for '#{name}' on #{remote_host}"
|
|
326
|
+
ssh_command = "ssh #{remote_host} 'cd #{remote_dir} && docker compose pull'"
|
|
327
|
+
result = system(ssh_command)
|
|
328
|
+
|
|
329
|
+
unless result
|
|
330
|
+
warn "Error: docker compose pull failed"
|
|
331
|
+
exit 1
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
puts "Successfully pulled latest images for app '#{name}' in namespace '#{namespace}'"
|
|
335
|
+
puts "Use 'whctl start app #{name} -n #{namespace}' to apply the pulled images"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def describe(namespace:, name:)
|
|
339
|
+
# Get namespace config
|
|
340
|
+
namespace_config = load_namespace_config(namespace)
|
|
341
|
+
username = namespace_config["username"]
|
|
342
|
+
hostname = namespace_config["hostname"]
|
|
343
|
+
|
|
344
|
+
remote_host = username ? "#{username}@#{hostname}" : hostname
|
|
345
|
+
remote_dir = "/data/walheim/apps/#{name}"
|
|
346
|
+
|
|
347
|
+
# Check if remote directory exists
|
|
348
|
+
check_command = "ssh #{remote_host} 'test -d #{remote_dir}'"
|
|
349
|
+
dir_exists = system(check_command)
|
|
350
|
+
|
|
351
|
+
unless dir_exists
|
|
352
|
+
warn "Error: app '#{name}' not found on #{remote_host}"
|
|
353
|
+
warn "Deploy the app first using 'whctl apply app #{name} -n #{namespace}'"
|
|
354
|
+
exit 1
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Display container status using docker compose ps
|
|
358
|
+
puts "Status of '#{name}' on #{remote_host}:\n\n"
|
|
359
|
+
|
|
360
|
+
# Get container status
|
|
361
|
+
ps_command = "ssh #{remote_host} 'cd #{remote_dir} && docker compose ps'"
|
|
362
|
+
system(ps_command)
|
|
363
|
+
|
|
364
|
+
puts "\n"
|
|
365
|
+
|
|
366
|
+
# Get resource usage for running containers
|
|
367
|
+
stats_command = "ssh #{remote_host} 'cd #{remote_dir} && docker compose ps -q | xargs -r docker stats --no-stream --format \"table {{.Name}}\\t{{.CPUPerc}}\\t{{.MemUsage}}\\t{{.NetIO}}\\t{{.BlockIO}}\"'"
|
|
368
|
+
|
|
369
|
+
puts "Resource Usage:"
|
|
370
|
+
stats_result = system(stats_command)
|
|
371
|
+
|
|
372
|
+
unless stats_result
|
|
373
|
+
puts "(No running containers or unable to fetch stats)"
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def exec_command(namespace:, name:, service: nil, interactive: false, command:)
|
|
378
|
+
# Get namespace config
|
|
379
|
+
namespace_config = load_namespace_config(namespace)
|
|
380
|
+
username = namespace_config["username"]
|
|
381
|
+
hostname = namespace_config["hostname"]
|
|
382
|
+
|
|
383
|
+
remote_host = username ? "#{username}@#{hostname}" : hostname
|
|
384
|
+
remote_dir = "/data/walheim/apps/#{name}"
|
|
385
|
+
|
|
386
|
+
# Check if remote directory exists
|
|
387
|
+
check_command = "ssh #{remote_host} 'test -d #{remote_dir}'"
|
|
388
|
+
dir_exists = system(check_command)
|
|
389
|
+
|
|
390
|
+
unless dir_exists
|
|
391
|
+
warn "Error: app '#{name}' not found on #{remote_host}"
|
|
392
|
+
warn "Deploy the app first using 'whctl apply app #{name} -n #{namespace}'"
|
|
393
|
+
exit 1
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Validate command
|
|
397
|
+
if command.nil? || command.empty?
|
|
398
|
+
warn "Error: command is required"
|
|
399
|
+
warn "Usage: whctl exec app {name} -n {namespace} [--] {command} [args...]"
|
|
400
|
+
exit 1
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Determine service name
|
|
404
|
+
service_name = service
|
|
405
|
+
unless service_name
|
|
406
|
+
# Get first service from docker-compose.yml
|
|
407
|
+
app_manifest = load_app_manifest(namespace, name)
|
|
408
|
+
services = app_manifest[:compose]["services"]
|
|
409
|
+
|
|
410
|
+
if services.nil? || services.empty?
|
|
411
|
+
warn "Error: no services found in app '#{name}'"
|
|
412
|
+
exit 1
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
service_name = services.keys.first
|
|
416
|
+
puts "Defaulting to service: #{service_name}"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Build docker compose exec command
|
|
420
|
+
# command is an array, so join with spaces and properly escape
|
|
421
|
+
command_str = Shellwords.join(command)
|
|
422
|
+
exec_cmd = "docker compose exec"
|
|
423
|
+
exec_cmd += " -T" unless interactive # Disable pseudo-TTY allocation when not interactive
|
|
424
|
+
exec_cmd += " -it" if interactive
|
|
425
|
+
exec_cmd += " #{Shellwords.escape(service_name)} #{command_str}"
|
|
426
|
+
|
|
427
|
+
# Build SSH command
|
|
428
|
+
# Use -t flag for SSH when interactive mode is requested
|
|
429
|
+
ssh_flags = interactive ? "-t" : ""
|
|
430
|
+
ssh_command = "ssh #{ssh_flags} #{remote_host} 'cd #{remote_dir} && #{exec_cmd}'"
|
|
431
|
+
|
|
432
|
+
# Use exec to replace current process for proper signal handling
|
|
433
|
+
Kernel.exec(ssh_command)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Override get to pre-fetch container status
|
|
437
|
+
def get(namespace:, name: nil)
|
|
438
|
+
# Determine which namespaces to fetch status for
|
|
439
|
+
namespaces_to_fetch = if namespace.nil?
|
|
440
|
+
# Fetching all namespaces
|
|
441
|
+
all_namespace_names
|
|
442
|
+
elsif name.nil?
|
|
443
|
+
# Fetching single namespace
|
|
444
|
+
[ namespace ]
|
|
445
|
+
else
|
|
446
|
+
# Fetching single resource
|
|
447
|
+
[ namespace ]
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Pre-fetch container status for relevant namespaces
|
|
451
|
+
fetch_container_status_batch(namespaces_to_fetch)
|
|
452
|
+
|
|
453
|
+
# Call parent get implementation
|
|
454
|
+
super
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
private
|
|
458
|
+
|
|
459
|
+
def all_namespace_names
|
|
460
|
+
namespaces_dir = File.join(@data_dir, "namespaces")
|
|
461
|
+
return [] unless Dir.exist?(namespaces_dir)
|
|
462
|
+
|
|
463
|
+
Dir.entries(namespaces_dir)
|
|
464
|
+
.select { |entry| File.directory?(File.join(namespaces_dir, entry)) && !entry.start_with?(".") }
|
|
465
|
+
.select { |entry| File.exist?(File.join(namespaces_dir, entry, ".namespace.yaml")) }
|
|
466
|
+
.sort
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def fetch_container_status_batch(namespaces)
|
|
470
|
+
# Initialize cache if not exists
|
|
471
|
+
@container_status_cache ||= {}
|
|
472
|
+
|
|
473
|
+
# Group namespaces by hostname to batch SSH calls
|
|
474
|
+
namespace_by_host = {}
|
|
475
|
+
|
|
476
|
+
namespaces.each do |ns|
|
|
477
|
+
begin
|
|
478
|
+
config = load_namespace_config(ns)
|
|
479
|
+
hostname = config["hostname"]
|
|
480
|
+
username = config["username"]
|
|
481
|
+
host_key = username ? "#{username}@#{hostname}" : hostname
|
|
482
|
+
|
|
483
|
+
namespace_by_host[host_key] ||= { namespaces: [], config: config }
|
|
484
|
+
namespace_by_host[host_key][:namespaces] << ns
|
|
485
|
+
rescue StandardError
|
|
486
|
+
# Skip if namespace config not found
|
|
487
|
+
next
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Fetch status from each unique host in parallel
|
|
492
|
+
# Use Parallel.map to parallelize SSH calls across hosts
|
|
493
|
+
results = Parallel.map(namespace_by_host, in_threads: namespace_by_host.size) do |host_key, data|
|
|
494
|
+
fetch_status_from_host(host_key, data[:namespaces])
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Merge results into cache
|
|
498
|
+
results.each do |result|
|
|
499
|
+
@container_status_cache.merge!(result) if result
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def fetch_status_from_host(remote_host, namespaces)
|
|
504
|
+
# Query all walheim-managed containers on this host
|
|
505
|
+
# Use docker ps with labels to get all containers in one call
|
|
506
|
+
# Use double quotes and escape for proper SSH transmission
|
|
507
|
+
docker_cmd = 'docker ps -a --filter label=walheim.managed=true --format "{{.Label \\"walheim.namespace\\"}}|{{.Label \\"walheim.app\\"}}|{{.State}}|{{.Status}}"'
|
|
508
|
+
|
|
509
|
+
ssh_command = "ssh #{remote_host} #{Shellwords.escape(docker_cmd)} 2>/dev/null"
|
|
510
|
+
|
|
511
|
+
output = `#{ssh_command}`
|
|
512
|
+
|
|
513
|
+
# Build result hash to return (thread-safe)
|
|
514
|
+
result = {}
|
|
515
|
+
|
|
516
|
+
# Parse output and populate result
|
|
517
|
+
# Format: namespace|appname|state|status
|
|
518
|
+
output.each_line do |line|
|
|
519
|
+
parts = line.strip.split("|")
|
|
520
|
+
next if parts.size < 4
|
|
521
|
+
|
|
522
|
+
ns = parts[0]
|
|
523
|
+
app_name = parts[1]
|
|
524
|
+
state = parts[2] # running, exited, paused, etc.
|
|
525
|
+
status_text = parts[3] # "Up 2 hours", "Exited (0) 5 minutes ago", etc.
|
|
526
|
+
|
|
527
|
+
# Only cache for namespaces we're querying
|
|
528
|
+
next unless namespaces.include?(ns)
|
|
529
|
+
|
|
530
|
+
cache_key = "#{ns}/#{app_name}"
|
|
531
|
+
result[cache_key] ||= { containers: [] }
|
|
532
|
+
result[cache_key][:containers] << {
|
|
533
|
+
state: state,
|
|
534
|
+
status: status_text
|
|
535
|
+
}
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Mark namespaces as queried (even if no containers found)
|
|
539
|
+
namespaces.each do |ns|
|
|
540
|
+
result["_queried_#{ns}"] = true
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
result
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def get_container_status(namespace, app_name)
|
|
547
|
+
cache_key = "#{namespace}/#{app_name}"
|
|
548
|
+
|
|
549
|
+
# Check if we've queried this namespace
|
|
550
|
+
return "Unknown" unless @container_status_cache&.dig("_queried_#{namespace}")
|
|
551
|
+
|
|
552
|
+
# Get cached container data
|
|
553
|
+
cached_data = @container_status_cache&.dig(cache_key)
|
|
554
|
+
return "NotFound" if cached_data.nil? || cached_data[:containers].empty?
|
|
555
|
+
|
|
556
|
+
# Aggregate status from all containers
|
|
557
|
+
containers = cached_data[:containers]
|
|
558
|
+
states = containers.map { |c| c[:state] }.uniq
|
|
559
|
+
|
|
560
|
+
if states.all? { |s| s == "running" }
|
|
561
|
+
"Running"
|
|
562
|
+
elsif states.all? { |s| s == "exited" }
|
|
563
|
+
"Stopped"
|
|
564
|
+
elsif states.include?("running")
|
|
565
|
+
"Degraded"
|
|
566
|
+
elsif states.include?("paused")
|
|
567
|
+
"Paused"
|
|
568
|
+
elsif states.include?("restarting")
|
|
569
|
+
"Restarting"
|
|
570
|
+
else
|
|
571
|
+
"Unknown"
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def get_container_ready(namespace, app_name)
|
|
576
|
+
cache_key = "#{namespace}/#{app_name}"
|
|
577
|
+
|
|
578
|
+
# Check if we've queried this namespace
|
|
579
|
+
return "-" unless @container_status_cache&.dig("_queried_#{namespace}")
|
|
580
|
+
|
|
581
|
+
# Get cached container data
|
|
582
|
+
cached_data = @container_status_cache&.dig(cache_key)
|
|
583
|
+
return "-" if cached_data.nil? || cached_data[:containers].empty?
|
|
584
|
+
|
|
585
|
+
# Count running vs total containers
|
|
586
|
+
containers = cached_data[:containers]
|
|
587
|
+
total = containers.size
|
|
588
|
+
running = containers.count { |c| c[:state] == "running" }
|
|
589
|
+
|
|
590
|
+
"#{running}/#{total}"
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# Override get_single_resource to inject actual status and ready
|
|
594
|
+
def get_single_resource(namespace, name)
|
|
595
|
+
result = super
|
|
596
|
+
|
|
597
|
+
# Override status and ready with actual container data
|
|
598
|
+
if result[:summary]
|
|
599
|
+
result[:summary][:status] = get_container_status(namespace, name)
|
|
600
|
+
result[:summary][:ready] = get_container_ready(namespace, name)
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
result
|
|
604
|
+
end
|
|
605
|
+
|
|
236
606
|
private
|
|
237
607
|
|
|
238
608
|
def load_app_manifest(namespace, name)
|
|
@@ -28,7 +28,12 @@ module Resources
|
|
|
28
28
|
get: {
|
|
29
29
|
description: "List all namespaces",
|
|
30
30
|
usage: [ "get namespaces" ],
|
|
31
|
-
options: {} # No namespace flag for cluster resource
|
|
31
|
+
options: {}, # No namespace flag for cluster resource
|
|
32
|
+
dispatch: {
|
|
33
|
+
method: :get,
|
|
34
|
+
params: [ :name ],
|
|
35
|
+
output: :table
|
|
36
|
+
}
|
|
32
37
|
},
|
|
33
38
|
create: {
|
|
34
39
|
description: "Create a new namespace",
|
|
@@ -36,6 +41,14 @@ module Resources
|
|
|
36
41
|
options: {
|
|
37
42
|
username: { type: :string, desc: "SSH username for namespace" },
|
|
38
43
|
hostname: { type: :string, desc: "Hostname for namespace" }
|
|
44
|
+
},
|
|
45
|
+
dispatch: {
|
|
46
|
+
method: :create,
|
|
47
|
+
params: [ :name ],
|
|
48
|
+
named_params: {
|
|
49
|
+
username: :username,
|
|
50
|
+
hostname: :hostname
|
|
51
|
+
}
|
|
39
52
|
}
|
|
40
53
|
},
|
|
41
54
|
apply: {
|
|
@@ -43,12 +56,32 @@ module Resources
|
|
|
43
56
|
usage: [ "apply namespace {name}", "apply -f namespace.yaml" ],
|
|
44
57
|
options: {
|
|
45
58
|
file: { type: :string, aliases: [ :f ], desc: "Manifest file" }
|
|
59
|
+
},
|
|
60
|
+
dispatch: {
|
|
61
|
+
method: :apply,
|
|
62
|
+
params: [ :name ],
|
|
63
|
+
named_params: {
|
|
64
|
+
manifest_source: :file
|
|
65
|
+
}
|
|
46
66
|
}
|
|
47
67
|
},
|
|
48
68
|
delete: {
|
|
49
69
|
description: "Delete a namespace",
|
|
50
70
|
usage: [ "delete namespace {name}" ],
|
|
51
|
-
options: {}
|
|
71
|
+
options: {},
|
|
72
|
+
dispatch: {
|
|
73
|
+
method: :delete,
|
|
74
|
+
params: [ :name ]
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
describe: {
|
|
78
|
+
description: "Show detailed namespace information",
|
|
79
|
+
usage: [ "describe namespace {name}" ],
|
|
80
|
+
options: {},
|
|
81
|
+
dispatch: {
|
|
82
|
+
method: :describe,
|
|
83
|
+
params: [ :name ]
|
|
84
|
+
}
|
|
52
85
|
}
|
|
53
86
|
}
|
|
54
87
|
end
|
|
@@ -79,8 +112,218 @@ module Resources
|
|
|
79
112
|
puts " Hostname: #{hostname}"
|
|
80
113
|
end
|
|
81
114
|
|
|
115
|
+
# Describe a namespace with detailed information
|
|
116
|
+
def describe(name:)
|
|
117
|
+
namespace_path = File.join(@data_dir, "namespaces", name)
|
|
118
|
+
unless Dir.exist?(namespace_path)
|
|
119
|
+
warn "Error: namespace '#{name}' not found"
|
|
120
|
+
exit 1
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Load namespace config
|
|
124
|
+
config_path = File.join(namespace_path, ".namespace.yaml")
|
|
125
|
+
unless File.exist?(config_path)
|
|
126
|
+
warn "Error: namespace config not found at #{config_path}"
|
|
127
|
+
exit 1
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
config = YAML.load_file(config_path)
|
|
131
|
+
username = config["username"]
|
|
132
|
+
hostname = config["hostname"]
|
|
133
|
+
remote_host = username ? "#{username}@#{hostname}" : hostname
|
|
134
|
+
|
|
135
|
+
# Print metadata
|
|
136
|
+
puts "Name: #{name}"
|
|
137
|
+
puts "Hostname: #{hostname}"
|
|
138
|
+
puts "Username: #{username || '(from SSH config)'}"
|
|
139
|
+
puts "SSH: #{remote_host}"
|
|
140
|
+
puts ""
|
|
141
|
+
|
|
142
|
+
# Test SSH connectivity and Docker availability
|
|
143
|
+
puts "Status:"
|
|
144
|
+
connection_status = test_ssh_connection(remote_host)
|
|
145
|
+
puts " Connection: #{connection_status[:status]}"
|
|
146
|
+
|
|
147
|
+
if connection_status[:connected]
|
|
148
|
+
docker_info = get_docker_info(remote_host)
|
|
149
|
+
puts " Docker: #{docker_info[:status]}"
|
|
150
|
+
puts ""
|
|
151
|
+
|
|
152
|
+
# Get deployed apps with status
|
|
153
|
+
apps_info = get_deployed_apps(name, remote_host)
|
|
154
|
+
if apps_info[:apps].any?
|
|
155
|
+
puts "Deployed Apps:"
|
|
156
|
+
apps_info[:apps].each do |app|
|
|
157
|
+
status_display = format_app_status(app[:status])
|
|
158
|
+
ready_display = app[:ready] || "-"
|
|
159
|
+
puts " %-15s %-12s %s" % [ app[:name], status_display, ready_display ]
|
|
160
|
+
end
|
|
161
|
+
puts ""
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Count resources
|
|
165
|
+
puts "Resources:"
|
|
166
|
+
apps_count = count_resources(namespace_path, "apps")
|
|
167
|
+
secrets_count = count_resources(namespace_path, "secrets")
|
|
168
|
+
configmaps_count = count_resources(namespace_path, "configmaps")
|
|
169
|
+
puts " Apps: #{apps_count}"
|
|
170
|
+
puts " Secrets: #{secrets_count}"
|
|
171
|
+
puts " ConfigMaps: #{configmaps_count}"
|
|
172
|
+
puts ""
|
|
173
|
+
|
|
174
|
+
# Show usage info if available
|
|
175
|
+
if docker_info[:connected]
|
|
176
|
+
usage_info = get_usage_info(remote_host)
|
|
177
|
+
if usage_info[:available]
|
|
178
|
+
puts "Usage:"
|
|
179
|
+
puts " Disk: #{usage_info[:disk]}" if usage_info[:disk]
|
|
180
|
+
puts " Containers: #{usage_info[:containers]}" if usage_info[:containers]
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
else
|
|
184
|
+
puts ""
|
|
185
|
+
puts "Unable to connect to namespace. Please check SSH configuration."
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
82
189
|
private
|
|
83
190
|
|
|
191
|
+
# Test SSH connection to remote host
|
|
192
|
+
def test_ssh_connection(remote_host)
|
|
193
|
+
# Try a simple SSH command with timeout
|
|
194
|
+
test_command = "ssh -o ConnectTimeout=5 -o BatchMode=yes #{remote_host} 'echo ok' 2>/dev/null"
|
|
195
|
+
result = `#{test_command}`.strip
|
|
196
|
+
|
|
197
|
+
if result == "ok"
|
|
198
|
+
{ connected: true, status: "Connected" }
|
|
199
|
+
else
|
|
200
|
+
{ connected: false, status: "Failed" }
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Get Docker version and availability
|
|
205
|
+
def get_docker_info(remote_host)
|
|
206
|
+
docker_command = "ssh -o ConnectTimeout=5 #{remote_host} 'docker --version' 2>/dev/null"
|
|
207
|
+
result = `#{docker_command}`.strip
|
|
208
|
+
|
|
209
|
+
if result.include?("Docker version")
|
|
210
|
+
version = result.match(/Docker version ([\d.]+)/)[1] rescue "unknown"
|
|
211
|
+
{ connected: true, status: "Available (v#{version})" }
|
|
212
|
+
else
|
|
213
|
+
{ connected: false, status: "Not available" }
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Get list of deployed apps with their status
|
|
218
|
+
def get_deployed_apps(namespace, remote_host)
|
|
219
|
+
require "shellwords"
|
|
220
|
+
|
|
221
|
+
# Query all containers for this namespace
|
|
222
|
+
docker_cmd = "docker ps -a --filter label=walheim.namespace=" + namespace + ' --format "{{.Label \\"walheim.app\\"}}|{{.State}}|{{.Status}}"'
|
|
223
|
+
ssh_command = "ssh #{remote_host} #{Shellwords.escape(docker_cmd)} 2>/dev/null"
|
|
224
|
+
output = `#{ssh_command}`
|
|
225
|
+
|
|
226
|
+
# Parse container data
|
|
227
|
+
apps_data = {}
|
|
228
|
+
output.each_line do |line|
|
|
229
|
+
parts = line.strip.split("|")
|
|
230
|
+
next if parts.size < 3
|
|
231
|
+
|
|
232
|
+
app_name = parts[0]
|
|
233
|
+
state = parts[1]
|
|
234
|
+
status_text = parts[2]
|
|
235
|
+
|
|
236
|
+
apps_data[app_name] ||= { containers: [] }
|
|
237
|
+
apps_data[app_name][:containers] << { state: state, status: status_text }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Convert to app list with aggregated status
|
|
241
|
+
apps = apps_data.map do |app_name, data|
|
|
242
|
+
containers = data[:containers]
|
|
243
|
+
total = containers.size
|
|
244
|
+
running = containers.count { |c| c[:state] == "running" }
|
|
245
|
+
ready = "#{running}/#{total}"
|
|
246
|
+
|
|
247
|
+
# Determine overall status
|
|
248
|
+
states = containers.map { |c| c[:state] }.uniq
|
|
249
|
+
status = if states.all? { |s| s == "running" }
|
|
250
|
+
"Running"
|
|
251
|
+
elsif states.all? { |s| s == "exited" }
|
|
252
|
+
"Stopped"
|
|
253
|
+
elsif states.include?("running")
|
|
254
|
+
"Degraded"
|
|
255
|
+
elsif states.include?("paused")
|
|
256
|
+
"Paused"
|
|
257
|
+
else
|
|
258
|
+
"Unknown"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
{ name: app_name, status: status, ready: ready }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
{ apps: apps.sort_by { |a| a[:name] } }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Format app status for display
|
|
268
|
+
def format_app_status(status)
|
|
269
|
+
case status
|
|
270
|
+
when "Running"
|
|
271
|
+
status
|
|
272
|
+
when "Degraded"
|
|
273
|
+
status
|
|
274
|
+
when "Stopped"
|
|
275
|
+
status
|
|
276
|
+
when "Paused"
|
|
277
|
+
status
|
|
278
|
+
else
|
|
279
|
+
status
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Count resources in a namespace directory
|
|
284
|
+
def count_resources(namespace_path, resource_type)
|
|
285
|
+
resource_dir = File.join(namespace_path, resource_type)
|
|
286
|
+
return 0 unless Dir.exist?(resource_dir)
|
|
287
|
+
|
|
288
|
+
Dir.entries(resource_dir)
|
|
289
|
+
.select { |entry| File.directory?(File.join(resource_dir, entry)) && !entry.start_with?(".") }
|
|
290
|
+
.size
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Get usage information from remote host
|
|
294
|
+
def get_usage_info(remote_host)
|
|
295
|
+
# Get disk usage
|
|
296
|
+
disk_command = "ssh #{remote_host} 'df -h /data 2>/dev/null | tail -1' 2>/dev/null"
|
|
297
|
+
disk_output = `#{disk_command}`.strip
|
|
298
|
+
disk_info = nil
|
|
299
|
+
if disk_output && !disk_output.empty?
|
|
300
|
+
parts = disk_output.split
|
|
301
|
+
disk_info = "#{parts[2]} / #{parts[1]}" if parts.size >= 4
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Get container counts
|
|
305
|
+
container_command = "ssh #{remote_host} 'docker ps -q | wc -l; docker ps -aq | wc -l' 2>/dev/null"
|
|
306
|
+
container_output = `#{container_command}`.strip
|
|
307
|
+
container_info = nil
|
|
308
|
+
if container_output && !container_output.empty?
|
|
309
|
+
lines = container_output.split("\n")
|
|
310
|
+
if lines.size == 2
|
|
311
|
+
running = lines[0].strip.to_i
|
|
312
|
+
total = lines[1].strip.to_i
|
|
313
|
+
stopped = total - running
|
|
314
|
+
container_info = "#{running} running"
|
|
315
|
+
container_info += ", #{stopped} stopped" if stopped > 0
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
{
|
|
320
|
+
available: disk_info || container_info,
|
|
321
|
+
disk: disk_info,
|
|
322
|
+
containers: container_info
|
|
323
|
+
}
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
|
|
84
327
|
def manifest_filename
|
|
85
328
|
".namespace.yaml"
|
|
86
329
|
end
|
data/lib/walheim/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: walheim
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Akhyar Amarullah
|
|
@@ -38,6 +38,20 @@ dependencies:
|
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '1.3'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: parallel
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.24'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.24'
|
|
41
55
|
- !ruby/object:Gem::Dependency
|
|
42
56
|
name: bundler
|
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|