kdeploy 0.1.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.
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kdeploy
4
+ # Inventory class for managing host inventory and configuration
5
+ class Inventory
6
+ attr_reader :hosts, :groups, :vars
7
+
8
+ def initialize(inventory_file = nil)
9
+ @hosts = {}
10
+ @groups = {}
11
+ @vars = {}
12
+ load_from_file(inventory_file) if inventory_file && File.exist?(inventory_file)
13
+ end
14
+
15
+ # Load inventory from YAML file
16
+ # @param inventory_file [String] Path to inventory file
17
+ # @raise [ConfigurationError] If inventory file is invalid
18
+ def load_from_file(inventory_file)
19
+ inventory_data = YAML.load_file(inventory_file)
20
+ parse_inventory(inventory_data)
21
+ rescue Psych::SyntaxError => e
22
+ raise ConfigurationError, "Invalid YAML syntax in inventory file: #{e.message}"
23
+ rescue StandardError => e
24
+ raise ConfigurationError, "Failed to load inventory file: #{e.message}"
25
+ end
26
+
27
+ # Get all hosts in a group
28
+ # @param group_name [String, Symbol] Group name
29
+ # @return [Array<Host>] Hosts in the group
30
+ def hosts_in_group(group_name)
31
+ group_name = group_name.to_s
32
+ return [] unless @groups[group_name]
33
+
34
+ @groups[group_name][:hosts].map { |hostname| @hosts[hostname] }.compact
35
+ end
36
+
37
+ # Get all hosts with specific role
38
+ # @param role [String, Symbol] Role name
39
+ # @return [Array<Host>] Hosts with the role
40
+ def hosts_with_role(role)
41
+ @hosts.values.select { |host| host.has_role?(role) }
42
+ end
43
+
44
+ # Get all hosts
45
+ # @return [Array<Host>] All hosts
46
+ def all_hosts
47
+ @hosts.values
48
+ end
49
+
50
+ # Get host by hostname
51
+ # @param hostname [String] Hostname
52
+ # @return [Host, nil] Host object or nil
53
+ def host(hostname)
54
+ @hosts[hostname]
55
+ end
56
+
57
+ # Get group variable
58
+ # @param group_name [String, Symbol] Group name
59
+ # @param var_name [String, Symbol] Variable name
60
+ # @return [Object] Variable value
61
+ def group_var(group_name, var_name)
62
+ group_name = group_name.to_s
63
+ return nil unless @groups[group_name]
64
+
65
+ @groups[group_name][:vars][var_name.to_s] || @groups[group_name][:vars][var_name.to_sym]
66
+ end
67
+
68
+ # Get global variable
69
+ # @param var_name [String, Symbol] Variable name
70
+ # @return [Object] Variable value
71
+ def global_var(var_name)
72
+ @vars[var_name.to_s] || @vars[var_name.to_sym]
73
+ end
74
+
75
+ # Export inventory summary
76
+ # @return [Hash] Inventory summary
77
+ def summary
78
+ {
79
+ total_hosts: @hosts.size,
80
+ total_groups: @groups.size,
81
+ hosts: @hosts.keys,
82
+ groups: @groups.keys
83
+ }
84
+ end
85
+
86
+ private
87
+
88
+ def parse_inventory(inventory_data)
89
+ return unless inventory_data.is_a?(Hash)
90
+
91
+ @vars = extract_vars(inventory_data)
92
+ parse_groups(inventory_data)
93
+ parse_hosts(inventory_data)
94
+ apply_group_variables
95
+ end
96
+
97
+ def extract_vars(data)
98
+ data['vars'] || data[:vars] || {}
99
+ end
100
+
101
+ def parse_groups(inventory_data)
102
+ groups_data = inventory_data['groups'] || inventory_data[:groups] || {}
103
+
104
+ groups_data.each do |group_name, group_config|
105
+ process_group(group_name.to_s, group_config || {})
106
+ end
107
+
108
+ resolve_group_children
109
+ end
110
+
111
+ def process_group(group_name, group_config)
112
+ @groups[group_name] = {
113
+ hosts: extract_group_hosts(group_config),
114
+ vars: extract_group_vars(group_config),
115
+ children: extract_group_children(group_config)
116
+ }
117
+ end
118
+
119
+ def extract_group_hosts(config)
120
+ Array(config['hosts'] || config[:hosts] || [])
121
+ end
122
+
123
+ def extract_group_vars(config)
124
+ config['vars'] || config[:vars] || {}
125
+ end
126
+
127
+ def extract_group_children(config)
128
+ Array(config['children'] || config[:children] || [])
129
+ end
130
+
131
+ def parse_hosts(inventory_data)
132
+ hosts_data = inventory_data['hosts'] || inventory_data[:hosts] || {}
133
+
134
+ hosts_data.each do |hostname, host_config|
135
+ process_host(hostname, host_config || {})
136
+ end
137
+ end
138
+
139
+ def process_host(hostname, host_config)
140
+ host_groups = find_host_groups(hostname)
141
+ host_roles = Array(host_config['roles'] || host_config[:roles] || host_groups)
142
+
143
+ @hosts[hostname] = create_host(hostname, host_config, host_roles)
144
+ end
145
+
146
+ def create_host(hostname, config, roles)
147
+ Host.new(
148
+ hostname,
149
+ user: config['user'] || config[:user],
150
+ port: config['port'] || config[:port],
151
+ ssh_options: parse_ssh_options(config),
152
+ roles: roles,
153
+ vars: config['vars'] || config[:vars] || {}
154
+ )
155
+ end
156
+
157
+ def parse_ssh_options(host_config)
158
+ ssh_config = host_config['ssh'] || host_config[:ssh] || {}
159
+ options = {}
160
+
161
+ process_ssh_key_options(ssh_config, options)
162
+ process_ssh_auth_options(ssh_config, options)
163
+ process_ssh_verification_options(ssh_config, options)
164
+ process_ssh_timeout_option(ssh_config, options)
165
+
166
+ options
167
+ end
168
+
169
+ def process_ssh_key_options(ssh_config, options)
170
+ if ssh_config['key_file'] || ssh_config[:key_file]
171
+ key_file = ssh_config['key_file'] || ssh_config[:key_file]
172
+ options[:keys] = [File.expand_path(key_file)]
173
+ end
174
+
175
+ return unless ssh_config['key_data'] || ssh_config[:key_data]
176
+
177
+ options[:key_data] = Array(ssh_config['key_data'] || ssh_config[:key_data])
178
+ end
179
+
180
+ def process_ssh_auth_options(ssh_config, options)
181
+ options[:password] = ssh_config['password'] || ssh_config[:password] if ssh_config['password'] || ssh_config[:password]
182
+ return unless ssh_config['passphrase'] || ssh_config[:passphrase]
183
+
184
+ options[:passphrase] = ssh_config['passphrase'] || ssh_config[:passphrase]
185
+ end
186
+
187
+ def process_ssh_verification_options(ssh_config, options)
188
+ return unless ssh_config.key?('verify_host_key') || ssh_config.key?(:verify_host_key)
189
+
190
+ verify_host_key = ssh_config['verify_host_key'] || ssh_config[:verify_host_key]
191
+ options[:verify_host_key] = verify_host_key ? :always : :never
192
+ end
193
+
194
+ def process_ssh_timeout_option(ssh_config, options)
195
+ options[:timeout] = ssh_config['timeout'] || ssh_config[:timeout] if ssh_config['timeout'] || ssh_config[:timeout]
196
+ end
197
+
198
+ def find_host_groups(hostname)
199
+ @groups.each_with_object([]) do |(group_name, group_config), groups|
200
+ groups << group_name if group_config[:hosts].include?(hostname)
201
+ end
202
+ end
203
+
204
+ def resolve_group_children
205
+ @groups.each do |group_name, group_config|
206
+ process_group_children(group_name, group_config)
207
+ end
208
+ end
209
+
210
+ def process_group_children(group_name, group_config)
211
+ group_config[:children].each do |child_group|
212
+ next unless @groups[child_group]
213
+
214
+ @groups[group_name][:hosts].concat(@groups[child_group][:hosts])
215
+ end
216
+
217
+ @groups[group_name][:hosts].uniq!
218
+ end
219
+
220
+ def apply_group_variables
221
+ @hosts.each do |hostname, host|
222
+ host_groups = find_host_groups(hostname)
223
+ apply_group_vars_to_host(host, host_groups)
224
+ apply_global_vars_to_host(host)
225
+ end
226
+ end
227
+
228
+ def apply_group_vars_to_host(host, host_groups)
229
+ host_groups.each do |group_name|
230
+ group_vars = @groups[group_name][:vars] || {}
231
+ group_vars.each do |key, value|
232
+ host.set_var(key, value) unless host.var(key)
233
+ end
234
+ end
235
+ end
236
+
237
+ def apply_global_vars_to_host(host)
238
+ @vars.each do |key, value|
239
+ host.set_var(key, value) unless host.var(key)
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kdeploy
4
+ # Custom logger class for Kdeploy with colorized output
5
+ class KdeployLogger
6
+ class << self
7
+ attr_accessor :instance
8
+
9
+ # Set up logger instance with specified level and output file
10
+ # @param level [Symbol] Log level (:debug, :info, :warn, :error, :fatal)
11
+ # @param file [String, IO] Output file or IO stream
12
+ # @return [KdeployLogger] Logger instance
13
+ def setup(level: :info, file: nil)
14
+ @instance = new(level: level, file: file)
15
+ end
16
+
17
+ def method_missing(method_name, ...)
18
+ return super unless respond_to_missing?(method_name, false)
19
+
20
+ @instance ||= new
21
+ @instance.send(method_name, ...)
22
+ end
23
+
24
+ def respond_to_missing?(method_name, include_private = false)
25
+ return true if %i[debug info warn error fatal].include?(method_name)
26
+
27
+ super
28
+ end
29
+ end
30
+
31
+ # Initialize logger with specified level and output file
32
+ # @param level [Symbol] Log level (:debug, :info, :warn, :error, :fatal)
33
+ # @param file [String, IO] Output file or IO stream
34
+ def initialize(level: :info, file: nil)
35
+ @logger = Logger.new(file || $stdout)
36
+ @logger.level = logger_level(level)
37
+ @logger.formatter = method(:format_message)
38
+ end
39
+
40
+ # Log debug message
41
+ # @param message [String] Message to log
42
+ def debug(message)
43
+ @logger.debug(message)
44
+ end
45
+
46
+ # Log info message
47
+ # @param message [String] Message to log
48
+ def info(message)
49
+ @logger.info(message)
50
+ end
51
+
52
+ # Log warning message
53
+ # @param message [String] Message to log
54
+ def warn(message)
55
+ @logger.warn(message)
56
+ end
57
+
58
+ # Log error message
59
+ # @param message [String] Message to log
60
+ def error(message)
61
+ @logger.error(message)
62
+ end
63
+
64
+ # Log fatal message
65
+ # @param message [String] Message to log
66
+ def fatal(message)
67
+ @logger.fatal(message)
68
+ end
69
+
70
+ private
71
+
72
+ def format_message(severity, datetime, _progname, msg)
73
+ timestamp = datetime.strftime('%Y-%m-%d %H:%M:%S')
74
+ colored_msg = colorize_message(severity, msg)
75
+ "[#{timestamp}] #{severity}: #{colored_msg}\n"
76
+ end
77
+
78
+ def logger_level(level)
79
+ case level.to_sym
80
+ when :debug then Logger::DEBUG
81
+ when :info then Logger::INFO
82
+ when :warn then Logger::WARN
83
+ when :error then Logger::ERROR
84
+ when :fatal then Logger::FATAL
85
+ else Logger::INFO
86
+ end
87
+ end
88
+
89
+ def colorize_message(severity, message)
90
+ case severity
91
+ when 'DEBUG' then message.colorize(:light_black)
92
+ when 'INFO' then message.colorize(:green)
93
+ when 'WARN' then message.colorize(:yellow)
94
+ when 'ERROR' then message.colorize(:red)
95
+ when 'FATAL' then message.colorize(:light_red)
96
+ else message
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kdeploy
4
+ # Pipeline class for managing deployment tasks and hosts
5
+ class Pipeline
6
+ attr_reader :name, :hosts, :tasks, :variables
7
+
8
+ def initialize(name = 'default')
9
+ @name = name
10
+ @hosts = []
11
+ @tasks = []
12
+ @variables = {}
13
+ end
14
+
15
+ # Add host to pipeline
16
+ # @param hostname [String] Hostname or IP address
17
+ # @param user [String] SSH user
18
+ # @param port [Integer] SSH port
19
+ # @param ssh_options [Hash] SSH options
20
+ # @param roles [Array] Host roles
21
+ # @param vars [Hash] Host variables
22
+ # @return [Host] Created host
23
+ def add_host(hostname, user: nil, port: nil, ssh_options: {}, roles: [], vars: {})
24
+ host = Host.new(
25
+ hostname,
26
+ user: user,
27
+ port: port,
28
+ ssh_options: ssh_options,
29
+ roles: roles,
30
+ vars: vars
31
+ )
32
+ @hosts << host unless @hosts.include?(host)
33
+ host
34
+ end
35
+
36
+ # Add multiple hosts from hash
37
+ # @param hosts_config [Hash] Hosts configuration
38
+ def add_hosts(hosts_config)
39
+ hosts_config.each do |hostname, config|
40
+ config ||= {}
41
+ add_host_from_config(hostname, config)
42
+ end
43
+ end
44
+
45
+ # Get hosts by role
46
+ # @param role [String, Symbol] Role to filter by
47
+ # @return [Array<Host>] Hosts with specified role
48
+ def hosts_with_role(role)
49
+ @hosts.select { |host| host.has_role?(role) }
50
+ end
51
+
52
+ # Add task to pipeline
53
+ # @param name [String] Task name
54
+ # @param hosts [Array<Host>] Target hosts (default: all hosts)
55
+ # @param options [Hash] Task options
56
+ # @return [Task] Created task
57
+ def add_task(name, hosts: nil, **options)
58
+ target_hosts = hosts || @hosts
59
+ task = create_task(name, target_hosts, options)
60
+ @tasks << task
61
+ task
62
+ end
63
+
64
+ # Set global variable
65
+ # @param key [String, Symbol] Variable key
66
+ # @param value [Object] Variable value
67
+ def set_variable(key, value)
68
+ @variables[key.to_s] = value
69
+ end
70
+
71
+ # Get global variable
72
+ # @param key [String, Symbol] Variable key
73
+ # @return [Object] Variable value
74
+ def get_variable(key)
75
+ @variables[key.to_s] || @variables[key.to_sym]
76
+ end
77
+
78
+ # Execute all tasks in pipeline
79
+ # @return [Hash] Execution results
80
+ def execute
81
+ return empty_execution_result if @tasks.empty?
82
+
83
+ log_pipeline_start
84
+ start_time = Time.now
85
+ results = execute_tasks
86
+ duration = Time.now - start_time
87
+ success_count = count_successful_tasks(results)
88
+
89
+ log_pipeline_completion(duration, success_count)
90
+ build_execution_result(results, duration, success_count)
91
+ end
92
+
93
+ # Get pipeline summary
94
+ # @return [Hash] Pipeline summary
95
+ def summary
96
+ {
97
+ name: @name,
98
+ hosts_count: @hosts.size,
99
+ tasks_count: @tasks.size,
100
+ hosts: @hosts.map(&:hostname),
101
+ tasks: @tasks.map(&:name)
102
+ }
103
+ end
104
+
105
+ # Validate pipeline configuration
106
+ # @return [Array<String>] Validation errors
107
+ def validate
108
+ errors = []
109
+ errors.concat(validate_pipeline_structure)
110
+ errors.concat(validate_hosts)
111
+ errors.concat(validate_tasks)
112
+ errors
113
+ end
114
+
115
+ # Check if pipeline is valid
116
+ # @return [Boolean] True if pipeline is valid
117
+ def valid?
118
+ validate.empty?
119
+ end
120
+
121
+ private
122
+
123
+ def add_host_from_config(hostname, config)
124
+ add_host(
125
+ hostname,
126
+ user: config['user'] || config[:user],
127
+ port: config['port'] || config[:port],
128
+ ssh_options: config['ssh_options'] || config[:ssh_options] || {},
129
+ roles: config['roles'] || config[:roles] || [],
130
+ vars: config['vars'] || config[:vars] || {}
131
+ )
132
+ end
133
+
134
+ def create_task(name, target_hosts, options)
135
+ task = Task.new(name, target_hosts, options)
136
+ task.global_variables = @variables
137
+ task
138
+ end
139
+
140
+ def empty_execution_result
141
+ { success: true, results: [], duration: 0 }
142
+ end
143
+
144
+ def log_pipeline_start
145
+ KdeployLogger.info(
146
+ "Starting pipeline '#{@name}' with #{@tasks.size} task(s) on #{@hosts.size} host(s)"
147
+ )
148
+ end
149
+
150
+ def execute_tasks
151
+ results = []
152
+ overall_success = true
153
+
154
+ @tasks.each_with_index do |task, index|
155
+ log_task_execution(task, index)
156
+ result = execute_task(task)
157
+ results << result
158
+ overall_success = false unless result[:success]
159
+ end
160
+
161
+ results
162
+ end
163
+
164
+ def log_task_execution(task, index)
165
+ KdeployLogger.info("Executing task #{index + 1}/#{@tasks.size}: '#{task.name}'")
166
+ end
167
+
168
+ def execute_task(task)
169
+ result = task.execute
170
+ log_task_failure(task) unless result[:success]
171
+
172
+ {
173
+ task_name: task.name,
174
+ **result
175
+ }
176
+ end
177
+
178
+ def log_task_failure(task)
179
+ KdeployLogger.error("Task '#{task.name}' failed, pipeline execution continuing...")
180
+ end
181
+
182
+ def count_successful_tasks(results)
183
+ results.count { |r| r[:success] }
184
+ end
185
+
186
+ def log_pipeline_completion(duration, success_count)
187
+ KdeployLogger.info(
188
+ "Pipeline '#{@name}' completed in #{duration.round(2)}s: " \
189
+ "#{success_count}/#{@tasks.size} tasks successful"
190
+ )
191
+ end
192
+
193
+ def build_execution_result(results, duration, success_count)
194
+ {
195
+ success: results.all? { |r| r[:success] },
196
+ results: results,
197
+ duration: duration,
198
+ tasks_count: @tasks.size,
199
+ success_count: success_count
200
+ }
201
+ end
202
+
203
+ def validate_pipeline_structure
204
+ errors = []
205
+ errors << 'No hosts defined' if @hosts.empty?
206
+ errors << 'No tasks defined' if @tasks.empty?
207
+ errors
208
+ end
209
+
210
+ def validate_hosts
211
+ @hosts.each_with_object([]) do |host, errors|
212
+ errors.concat(validate_host(host))
213
+ end
214
+ end
215
+
216
+ def validate_host(host)
217
+ errors = []
218
+ errors << "Invalid hostname: #{host.hostname}" if invalid_hostname?(host)
219
+ errors << "Invalid user: #{host.user}" if invalid_user?(host)
220
+ errors << "Invalid port: #{host.port}" if invalid_port?(host)
221
+ errors
222
+ end
223
+
224
+ def invalid_hostname?(host)
225
+ host.hostname.nil? || host.hostname.empty?
226
+ end
227
+
228
+ def invalid_user?(host)
229
+ host.user.nil? || host.user.empty?
230
+ end
231
+
232
+ def invalid_port?(host)
233
+ !host.port.is_a?(Integer) || !host.port.positive?
234
+ end
235
+
236
+ def validate_tasks
237
+ @tasks.each_with_object([]) do |task, errors|
238
+ errors.concat(validate_task(task))
239
+ end
240
+ end
241
+
242
+ def validate_task(task)
243
+ errors = []
244
+ errors << "Task '#{task.name}' has no commands" if task.commands.empty?
245
+ errors << "Task '#{task.name}' has no hosts" if task.hosts.empty?
246
+ errors
247
+ end
248
+ end
249
+ end