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.
@@ -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