simple_infrastructure 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +301 -0
- data/bin/simple_infrastructure +6 -0
- data/lib/generators/simple_infrastructure/change_generator.rb +16 -0
- data/lib/generators/simple_infrastructure/templates/change.rb.tt +21 -0
- data/lib/simple_infrastructure/change.rb +187 -0
- data/lib/simple_infrastructure/cli.rb +306 -0
- data/lib/simple_infrastructure/configuration.rb +36 -0
- data/lib/simple_infrastructure/dry_run_connection.rb +34 -0
- data/lib/simple_infrastructure/dsl.rb +53 -0
- data/lib/simple_infrastructure/file_operations.rb +48 -0
- data/lib/simple_infrastructure/inventory.rb +44 -0
- data/lib/simple_infrastructure/railtie.rb +22 -0
- data/lib/simple_infrastructure/runner.rb +162 -0
- data/lib/simple_infrastructure/server.rb +39 -0
- data/lib/simple_infrastructure/ssh_connection.rb +91 -0
- data/lib/simple_infrastructure/toml_operations.rb +154 -0
- data/lib/simple_infrastructure/version.rb +5 -0
- data/lib/simple_infrastructure/yaml_operations.rb +79 -0
- data/lib/simple_infrastructure.rb +57 -0
- data/lib/tasks/simple_infrastructure.rake +79 -0
- metadata +96 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleInfrastructure
|
|
4
|
+
class Cli
|
|
5
|
+
SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
6
|
+
RED = "\e[31m"
|
|
7
|
+
GREEN = "\e[1;92m"
|
|
8
|
+
YELLOW = "\e[33m"
|
|
9
|
+
RESET = "\e[0m"
|
|
10
|
+
CLEAR_LINE = "\r\e[K"
|
|
11
|
+
|
|
12
|
+
def initialize(argv)
|
|
13
|
+
@argv = argv
|
|
14
|
+
@results = []
|
|
15
|
+
@dry_run = false
|
|
16
|
+
@verbose = false
|
|
17
|
+
@args = []
|
|
18
|
+
parse_options!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
if @args.empty?
|
|
23
|
+
usage
|
|
24
|
+
exit 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
command = @args.shift
|
|
28
|
+
|
|
29
|
+
case command
|
|
30
|
+
when "new"
|
|
31
|
+
generate_change(@args.first)
|
|
32
|
+
when "status"
|
|
33
|
+
run_status
|
|
34
|
+
when /\./
|
|
35
|
+
run_hostname(command)
|
|
36
|
+
else
|
|
37
|
+
run_environment(command)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def parse_options!
|
|
44
|
+
@argv.each do |arg|
|
|
45
|
+
case arg
|
|
46
|
+
when "--dry-run", "-n"
|
|
47
|
+
@dry_run = true
|
|
48
|
+
when "--verbose", "-v"
|
|
49
|
+
@verbose = true
|
|
50
|
+
when "--help", "-h"
|
|
51
|
+
usage
|
|
52
|
+
exit 0
|
|
53
|
+
else
|
|
54
|
+
@args << arg
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def usage
|
|
60
|
+
puts <<~USAGE
|
|
61
|
+
Usage: #{$0} [options] <command>
|
|
62
|
+
|
|
63
|
+
Commands:
|
|
64
|
+
<environment> Run pending changes on all servers in environment
|
|
65
|
+
<hostname> Run pending changes on specific server
|
|
66
|
+
status Show change status for all servers
|
|
67
|
+
new <name> Generate a new change file
|
|
68
|
+
|
|
69
|
+
Options:
|
|
70
|
+
--dry-run, -n Show what would be done without executing
|
|
71
|
+
--verbose, -v Show detailed output (e.g., list pending changes in status)
|
|
72
|
+
--help, -h Show this help
|
|
73
|
+
|
|
74
|
+
Examples:
|
|
75
|
+
#{$0} status
|
|
76
|
+
#{$0} status -v
|
|
77
|
+
#{$0} new setup_redis
|
|
78
|
+
#{$0} --dry-run staging
|
|
79
|
+
#{$0} production
|
|
80
|
+
#{$0} web1.production.example.com
|
|
81
|
+
USAGE
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def generate_change(name)
|
|
85
|
+
unless name
|
|
86
|
+
puts "Usage: #{$0} new <change_name>"
|
|
87
|
+
puts "Example: #{$0} new setup_redis"
|
|
88
|
+
exit 1
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
|
|
92
|
+
filename = "#{timestamp}_#{name}.rb"
|
|
93
|
+
path = File.join(SimpleInfrastructure.configuration.changes_dir, filename)
|
|
94
|
+
|
|
95
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
96
|
+
|
|
97
|
+
template = <<~RUBY
|
|
98
|
+
target env: :production
|
|
99
|
+
|
|
100
|
+
# run "command", sudo: true
|
|
101
|
+
|
|
102
|
+
# file "/path/to/file", sudo: true do
|
|
103
|
+
# contains "line that must exist"
|
|
104
|
+
# remove "line that must not exist"
|
|
105
|
+
# on_change { run "systemctl restart service", sudo: true }
|
|
106
|
+
# end
|
|
107
|
+
|
|
108
|
+
# yaml "/path/to/config.yml", sudo: true do
|
|
109
|
+
# set "key.path", "value"
|
|
110
|
+
# remove "old.key"
|
|
111
|
+
# end
|
|
112
|
+
|
|
113
|
+
# toml "/path/to/config.toml", sudo: true do
|
|
114
|
+
# set "key.path", "value"
|
|
115
|
+
# remove "old.key"
|
|
116
|
+
# end
|
|
117
|
+
|
|
118
|
+
# upload "config/backup/script.sh", "/root/bin/script.sh", sudo: true, mode: "700"
|
|
119
|
+
RUBY
|
|
120
|
+
|
|
121
|
+
File.write(path, template)
|
|
122
|
+
puts "Created: #{path}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def run_status
|
|
126
|
+
runner = SimpleInfrastructure::Runner.new(dry_run: @dry_run)
|
|
127
|
+
by_env = runner.inventory.servers.group_by(&:env)
|
|
128
|
+
|
|
129
|
+
puts "🔧 Infrastructure Status -------------------------------------------------------\n\n"
|
|
130
|
+
|
|
131
|
+
by_env.keys.sort.each_with_index do |env, env_index|
|
|
132
|
+
servers = by_env[env]
|
|
133
|
+
|
|
134
|
+
title = env.to_s.split("_").map(&:capitalize).join(" ")
|
|
135
|
+
divider = "-" * (72 - title.length - 1)
|
|
136
|
+
puts "#{title} #{divider}"
|
|
137
|
+
|
|
138
|
+
servers.each do |server|
|
|
139
|
+
pending, error = with_spinner(server.hostname) do
|
|
140
|
+
runner.check_server_status(server)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
if error
|
|
144
|
+
puts " #{YELLOW}?#{RESET} #{server.hostname} (unable to connect)"
|
|
145
|
+
elsif pending.empty?
|
|
146
|
+
puts " #{GREEN}✔︎#{RESET} #{server.hostname} (up to date)"
|
|
147
|
+
else
|
|
148
|
+
puts " #{RED}✗#{RESET} #{server.hostname} (#{pending.count} pending)"
|
|
149
|
+
if @verbose
|
|
150
|
+
pending.each { |c| puts " #{c.name}" }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
puts if env_index < by_env.keys.length - 1
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def run_hostname(hostname)
|
|
160
|
+
runner = SimpleInfrastructure::Runner.new(dry_run: @dry_run)
|
|
161
|
+
server = runner.inventory.find_by_hostname(hostname)
|
|
162
|
+
|
|
163
|
+
unless server
|
|
164
|
+
puts "#{RED}Server not found: #{hostname}#{RESET}"
|
|
165
|
+
exit 1
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
puts "🔧 Infrastructure Run#{@dry_run ? ' (DRY RUN)' : ''} " \
|
|
169
|
+
"-------------------------------------------------------\n\n"
|
|
170
|
+
|
|
171
|
+
step(server.to_s, show_output: @dry_run) do
|
|
172
|
+
runner.run_for_server(server)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
puts
|
|
176
|
+
|
|
177
|
+
if passed?
|
|
178
|
+
puts "🎉 All changes applied successfully"
|
|
179
|
+
exit 0
|
|
180
|
+
else
|
|
181
|
+
puts "💥 #{failure_count} server#{'s' if failure_count != 1} failed"
|
|
182
|
+
exit 1
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def run_environment(env)
|
|
187
|
+
runner = SimpleInfrastructure::Runner.new(dry_run: @dry_run)
|
|
188
|
+
servers = runner.inventory.find_by_env(env)
|
|
189
|
+
|
|
190
|
+
if servers.empty?
|
|
191
|
+
puts "#{RED}No servers found for environment: #{env}#{RESET}"
|
|
192
|
+
exit 1
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
puts "🔧 Infrastructure Run — #{env}#{@dry_run ? ' (DRY RUN)' : ''} " \
|
|
196
|
+
"-------------------------------------------------------\n\n"
|
|
197
|
+
|
|
198
|
+
servers.each do |server|
|
|
199
|
+
step(server.to_s, show_output: @dry_run) do
|
|
200
|
+
runner.run_for_server(server)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
puts
|
|
205
|
+
|
|
206
|
+
if passed?
|
|
207
|
+
puts "🎉 All servers updated successfully"
|
|
208
|
+
exit 0
|
|
209
|
+
else
|
|
210
|
+
puts "💥 #{failure_count} server#{'s' if failure_count != 1} failed"
|
|
211
|
+
exit 1
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def with_spinner(label)
|
|
216
|
+
frame_index = 0
|
|
217
|
+
done = false
|
|
218
|
+
result = nil
|
|
219
|
+
error = nil
|
|
220
|
+
|
|
221
|
+
thread = Thread.new do
|
|
222
|
+
result = yield
|
|
223
|
+
rescue => e
|
|
224
|
+
error = e
|
|
225
|
+
ensure
|
|
226
|
+
done = true
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
until done
|
|
230
|
+
print "#{CLEAR_LINE}#{SPINNER_FRAMES[frame_index % SPINNER_FRAMES.size]} #{label}"
|
|
231
|
+
$stdout.flush
|
|
232
|
+
frame_index += 1
|
|
233
|
+
sleep 0.08
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
thread.join
|
|
237
|
+
print CLEAR_LINE
|
|
238
|
+
|
|
239
|
+
[result, error]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def step(label, show_output: false)
|
|
243
|
+
output_capture = StringIO.new
|
|
244
|
+
captured_logger = ::Logger.new(output_capture)
|
|
245
|
+
captured_logger.formatter = proc { |_sev, _time, _prog, msg| "#{msg}\n" }
|
|
246
|
+
|
|
247
|
+
original_logger = SimpleInfrastructure.configuration.logger
|
|
248
|
+
SimpleInfrastructure.configuration.logger = captured_logger
|
|
249
|
+
|
|
250
|
+
frame_index = 0
|
|
251
|
+
done = false
|
|
252
|
+
success = false
|
|
253
|
+
error = nil
|
|
254
|
+
|
|
255
|
+
thread = Thread.new do
|
|
256
|
+
success = yield
|
|
257
|
+
rescue => e
|
|
258
|
+
error = e
|
|
259
|
+
ensure
|
|
260
|
+
done = true
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
until done
|
|
264
|
+
print "#{CLEAR_LINE}#{SPINNER_FRAMES[frame_index % SPINNER_FRAMES.size]} #{label}"
|
|
265
|
+
$stdout.flush
|
|
266
|
+
frame_index += 1
|
|
267
|
+
sleep 0.08
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
thread.join
|
|
271
|
+
SimpleInfrastructure.configuration.logger = original_logger
|
|
272
|
+
|
|
273
|
+
output = output_capture.string
|
|
274
|
+
.gsub(/\e\[[0-9;]*[A-Za-z]/, "")
|
|
275
|
+
.gsub(/\r/, "")
|
|
276
|
+
.lines
|
|
277
|
+
.reject { |l| l.strip.empty? || l.include?("connecting...") || l.include?(": up to date") }
|
|
278
|
+
.join
|
|
279
|
+
.strip
|
|
280
|
+
|
|
281
|
+
if error || !success
|
|
282
|
+
print "#{CLEAR_LINE}#{RED}⚠️ #{label}#{RESET}\n"
|
|
283
|
+
puts output unless output.empty?
|
|
284
|
+
puts " #{error.message}" if error
|
|
285
|
+
puts
|
|
286
|
+
@results << false
|
|
287
|
+
elsif show_output && !output.empty?
|
|
288
|
+
print "#{CLEAR_LINE}⏳ #{label}\n"
|
|
289
|
+
puts output
|
|
290
|
+
puts
|
|
291
|
+
@results << true
|
|
292
|
+
else
|
|
293
|
+
print "#{CLEAR_LINE}✅ #{label}\n"
|
|
294
|
+
@results << true
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def passed?
|
|
299
|
+
@results.all?
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def failure_count
|
|
303
|
+
@results.count(false)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module SimpleInfrastructure
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_accessor :project_root, :logger
|
|
8
|
+
attr_writer :config_dir
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@project_root = Dir.pwd
|
|
12
|
+
@logger = default_logger
|
|
13
|
+
@config_dir = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def config_dir
|
|
17
|
+
@config_dir || File.join(project_root, "config", "infrastructure")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def changes_dir
|
|
21
|
+
File.join(config_dir, "changes")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def inventory_path
|
|
25
|
+
File.join(config_dir, "inventory.yml")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def default_logger
|
|
31
|
+
logger = ::Logger.new($stdout)
|
|
32
|
+
logger.formatter = proc { |_sev, _time, _prog, msg| "#{msg}\n" }
|
|
33
|
+
logger
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleInfrastructure
|
|
4
|
+
# For dry-run mode
|
|
5
|
+
class DryRunConnection
|
|
6
|
+
attr_reader :server
|
|
7
|
+
|
|
8
|
+
def initialize(server)
|
|
9
|
+
@server = server
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def connect
|
|
13
|
+
self
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def disconnect; end
|
|
17
|
+
|
|
18
|
+
def exec(_command)
|
|
19
|
+
{ stdout: "", stderr: "", exit_code: 0, success: true }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def read_file(_path, sudo: false) # rubocop:disable Lint/UnusedMethodArgument
|
|
23
|
+
""
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def write_file(path, content, sudo: false)
|
|
27
|
+
SimpleInfrastructure.logger.debug " Would write #{content.lines.count} lines to #{path}#{' (sudo)' if sudo}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def file_exists?(_path, sudo: false) # rubocop:disable Lint/UnusedMethodArgument
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleInfrastructure
|
|
4
|
+
module Dsl
|
|
5
|
+
class ChangeBuilder
|
|
6
|
+
attr_reader :change
|
|
7
|
+
|
|
8
|
+
def initialize(name)
|
|
9
|
+
@change = Change.new(name)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def target(...)
|
|
13
|
+
change.target(...)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run(command, sudo: false)
|
|
17
|
+
change.add_step(RunStep.new(command, sudo: sudo))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def file(path, sudo: false, &block)
|
|
21
|
+
step = FileStep.new(path, sudo: sudo)
|
|
22
|
+
step.instance_eval(&block) if block_given?
|
|
23
|
+
change.add_step(step)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def yaml(path, sudo: false, &block)
|
|
27
|
+
step = YamlStep.new(path, sudo: sudo)
|
|
28
|
+
step.instance_eval(&block) if block_given?
|
|
29
|
+
change.add_step(step)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def toml(path, sudo: false, &block)
|
|
33
|
+
step = TomlStep.new(path, sudo: sudo)
|
|
34
|
+
step.instance_eval(&block) if block_given?
|
|
35
|
+
change.add_step(step)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def upload(local_path, remote_path, sudo: false, mode: nil)
|
|
39
|
+
change.add_step(UploadStep.new(local_path, remote_path, sudo: sudo, mode: mode))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def load_file(path)
|
|
45
|
+
# Extract change name from filename: 20250120143000_setup_nginx.rb -> 20250120143000_setup_nginx
|
|
46
|
+
name = File.basename(path, ".rb")
|
|
47
|
+
builder = ChangeBuilder.new(name)
|
|
48
|
+
builder.instance_eval(File.read(path), path)
|
|
49
|
+
builder.change
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleInfrastructure
|
|
4
|
+
class FileOperations
|
|
5
|
+
def initialize(connection, path, sudo: false, dry_run: false)
|
|
6
|
+
@connection = connection
|
|
7
|
+
@path = path
|
|
8
|
+
@sudo = sudo
|
|
9
|
+
@dry_run = dry_run
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def apply(operations)
|
|
13
|
+
content = @connection.read_file(@path, sudo: @sudo)
|
|
14
|
+
lines = content.lines.map(&:chomp)
|
|
15
|
+
modified = false
|
|
16
|
+
|
|
17
|
+
operations.each do |op, *args|
|
|
18
|
+
case op
|
|
19
|
+
when :contains
|
|
20
|
+
line = args[0]
|
|
21
|
+
unless lines.include?(line.chomp)
|
|
22
|
+
SimpleInfrastructure.logger.debug " + #{line}"
|
|
23
|
+
lines << line.chomp
|
|
24
|
+
modified = true
|
|
25
|
+
end
|
|
26
|
+
when :remove
|
|
27
|
+
line = args[0].chomp
|
|
28
|
+
if lines.include?(line)
|
|
29
|
+
SimpleInfrastructure.logger.debug " - #{line}"
|
|
30
|
+
lines.delete(line)
|
|
31
|
+
modified = true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if modified
|
|
37
|
+
new_content = "#{lines.join("\n")}\n"
|
|
38
|
+
unless @dry_run
|
|
39
|
+
@connection.write_file(@path, new_content, sudo: @sudo)
|
|
40
|
+
end
|
|
41
|
+
else
|
|
42
|
+
SimpleInfrastructure.logger.debug " (no changes needed)"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
modified
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleInfrastructure
|
|
4
|
+
class Inventory
|
|
5
|
+
attr_reader :servers, :path, :defaults
|
|
6
|
+
|
|
7
|
+
def initialize(path = nil)
|
|
8
|
+
@path = path || SimpleInfrastructure.configuration.inventory_path
|
|
9
|
+
@erb_path = @path.sub(/\.yml$/, ".yml.erb")
|
|
10
|
+
@servers = []
|
|
11
|
+
@defaults = {}
|
|
12
|
+
load!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def load!
|
|
16
|
+
content = if File.exist?(@erb_path)
|
|
17
|
+
ERB.new(File.read(@erb_path)).result
|
|
18
|
+
elsif File.exist?(@path)
|
|
19
|
+
File.read(@path)
|
|
20
|
+
else
|
|
21
|
+
raise "No inventory found at #{@path} or #{@erb_path}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
data = YAML.safe_load(content, permitted_classes: [Symbol]) || {}
|
|
25
|
+
@defaults = (data["defaults"] || {}).transform_keys(&:to_sym)
|
|
26
|
+
|
|
27
|
+
@servers = []
|
|
28
|
+
(data["servers"] || {}).each do |env, server_list|
|
|
29
|
+
(server_list || []).each do |attrs|
|
|
30
|
+
merged = defaults.merge(attrs.transform_keys(&:to_sym)).merge(env: env)
|
|
31
|
+
@servers << Server.new(**merged)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def find_by_hostname(hostname)
|
|
37
|
+
servers.find { |s| s.hostname == hostname }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def find_by_env(env)
|
|
41
|
+
servers.select { |s| s.env == env.to_sym }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module SimpleInfrastructure
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
initializer "simple_infrastructure.configure" do
|
|
8
|
+
SimpleInfrastructure.configure do |config|
|
|
9
|
+
config.project_root = Rails.root.to_s
|
|
10
|
+
config.logger = Rails.logger
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
rake_tasks do
|
|
15
|
+
load File.expand_path("../tasks/simple_infrastructure.rake", __dir__)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
generators do
|
|
19
|
+
require_relative "../generators/simple_infrastructure/change_generator"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleInfrastructure
|
|
4
|
+
class Runner
|
|
5
|
+
REMOTE_STATE_DIR = ".infrastructure"
|
|
6
|
+
|
|
7
|
+
attr_reader :inventory, :changes, :dry_run
|
|
8
|
+
|
|
9
|
+
def initialize(dry_run: false)
|
|
10
|
+
@inventory = Inventory.new
|
|
11
|
+
@changes = Change.load_all.sort
|
|
12
|
+
@dry_run = dry_run
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run_for_env(env)
|
|
16
|
+
servers = inventory.find_by_env(env)
|
|
17
|
+
if servers.empty?
|
|
18
|
+
SimpleInfrastructure.logger.debug "No servers found for environment: #{env}"
|
|
19
|
+
return false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
SimpleInfrastructure.logger.debug "Running changes for #{servers.length} server(s) in #{env}..."
|
|
23
|
+
success = true
|
|
24
|
+
servers.each do |server|
|
|
25
|
+
success &= run_for_server(server)
|
|
26
|
+
end
|
|
27
|
+
success
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run_for_hostname(hostname)
|
|
31
|
+
server = inventory.find_by_hostname(hostname)
|
|
32
|
+
unless server
|
|
33
|
+
SimpleInfrastructure.logger.debug "Server not found: #{hostname}"
|
|
34
|
+
return false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
run_for_server(server)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run_for_server(server)
|
|
41
|
+
# Show connecting message
|
|
42
|
+
SimpleInfrastructure.logger.debug "\n#{server}: connecting..."
|
|
43
|
+
$stdout.flush
|
|
44
|
+
|
|
45
|
+
# Always use real SSH to check current state
|
|
46
|
+
ssh_connection = SshConnection.new(server)
|
|
47
|
+
ssh_connection.connect
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
applied = get_applied_changes(ssh_connection)
|
|
51
|
+
pending = changes.reject { |c| applied.include?(c.name) }
|
|
52
|
+
.select { |c| c.targets?(server) }
|
|
53
|
+
|
|
54
|
+
# Clear line and show result
|
|
55
|
+
SimpleInfrastructure.logger.debug "\r\e[K"
|
|
56
|
+
if pending.empty?
|
|
57
|
+
SimpleInfrastructure.logger.debug "#{server}: up to date"
|
|
58
|
+
return true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
SimpleInfrastructure.logger.debug "#{server}: #{pending.length} pending change(s)"
|
|
62
|
+
|
|
63
|
+
# For execution, use DryRunConnection in dry-run mode
|
|
64
|
+
connection = dry_run ? DryRunConnection.new(server) : ssh_connection
|
|
65
|
+
|
|
66
|
+
# Ensure state directory exists
|
|
67
|
+
ssh_connection.exec("mkdir -p ~/#{REMOTE_STATE_DIR}") unless dry_run
|
|
68
|
+
|
|
69
|
+
pending.each_with_index do |change, index|
|
|
70
|
+
SimpleInfrastructure.logger.debug " APPLY: #{change.name}"
|
|
71
|
+
log_output = []
|
|
72
|
+
|
|
73
|
+
change.steps.each do |step|
|
|
74
|
+
output = step.execute(connection, dry_run: dry_run)
|
|
75
|
+
log_output << output if output.is_a?(String)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
unless dry_run
|
|
79
|
+
# Write completion log file
|
|
80
|
+
log_content = "Change applied at: #{Time.now.iso8601}\n\n#{log_output.join("\n")}"
|
|
81
|
+
ssh_connection.write_file("~/#{REMOTE_STATE_DIR}/#{change.name}.log", log_content)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
SimpleInfrastructure.logger.debug " DONE: #{change.name}"
|
|
85
|
+
SimpleInfrastructure.logger.debug "" if index < pending.length - 1
|
|
86
|
+
end
|
|
87
|
+
true
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
SimpleInfrastructure.logger.debug "\r\e[K"
|
|
90
|
+
SimpleInfrastructure.logger.debug "#{server}: FAILED - #{e.message}"
|
|
91
|
+
SimpleInfrastructure.logger.debug e.backtrace.first(5).map { |l| " #{l}" }.join("\n")
|
|
92
|
+
false
|
|
93
|
+
ensure
|
|
94
|
+
ssh_connection.disconnect
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def check_server_status(server)
|
|
99
|
+
connection = SshConnection.new(server)
|
|
100
|
+
connection.connect
|
|
101
|
+
applied = get_applied_changes(connection)
|
|
102
|
+
connection.disconnect
|
|
103
|
+
changes.reject { |c| applied.include?(c.name) }
|
|
104
|
+
.select { |c| c.targets?(server) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def get_applied_changes(connection)
|
|
108
|
+
# List all .log files in the state directory
|
|
109
|
+
result = connection.exec("ls ~/#{REMOTE_STATE_DIR}/*.log 2>/dev/null || true")
|
|
110
|
+
return [] if result[:stdout].strip.empty?
|
|
111
|
+
|
|
112
|
+
result[:stdout].strip.split("\n").map do |path|
|
|
113
|
+
# Extract change name from path: ~/.infrastructure/20250121000100_install_essentials.log
|
|
114
|
+
File.basename(path, ".log")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def status(verbose: false)
|
|
119
|
+
by_env = inventory.servers.group_by(&:env)
|
|
120
|
+
|
|
121
|
+
by_env.keys.sort.each_with_index do |env, env_index|
|
|
122
|
+
servers = by_env[env]
|
|
123
|
+
|
|
124
|
+
title = env.to_s.split("_").map(&:capitalize).join(" ")
|
|
125
|
+
divider = "-" * (72 - title.length - 1)
|
|
126
|
+
SimpleInfrastructure.logger.debug "#{title} #{divider}"
|
|
127
|
+
|
|
128
|
+
servers.each do |server|
|
|
129
|
+
# Show connecting message, then overwrite with result
|
|
130
|
+
SimpleInfrastructure.logger.debug " ⋯ #{server.hostname} (connecting...)"
|
|
131
|
+
$stdout.flush
|
|
132
|
+
|
|
133
|
+
begin
|
|
134
|
+
connection = SshConnection.new(server)
|
|
135
|
+
connection.connect
|
|
136
|
+
applied = get_applied_changes(connection)
|
|
137
|
+
connection.disconnect
|
|
138
|
+
|
|
139
|
+
pending = changes.reject { |c| applied.include?(c.name) }
|
|
140
|
+
.select { |c| c.targets?(server) }
|
|
141
|
+
|
|
142
|
+
# Clear line and show result
|
|
143
|
+
SimpleInfrastructure.logger.debug "\r\e[K"
|
|
144
|
+
if pending.empty?
|
|
145
|
+
SimpleInfrastructure.logger.debug " \e[1;92m✔︎\e[0m #{server.hostname} (up to date)"
|
|
146
|
+
else
|
|
147
|
+
SimpleInfrastructure.logger.debug " \e[31m✗\e[0m #{server.hostname} (#{pending.count} pending)"
|
|
148
|
+
if verbose
|
|
149
|
+
pending.each { |c| SimpleInfrastructure.logger.debug " #{c.name}" }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
rescue StandardError
|
|
153
|
+
SimpleInfrastructure.logger.debug "\r\e[K"
|
|
154
|
+
SimpleInfrastructure.logger.debug " \e[33m?\e[0m #{server.hostname} (unable to connect)"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
SimpleInfrastructure.logger.debug "" if env_index < by_env.keys.length - 1
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|