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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleInfrastructure
4
+ class Server
5
+ attr_reader :hostname, :env, :user, :attributes
6
+
7
+ def initialize(hostname:, env:, user: "root", **attributes)
8
+ @hostname = hostname
9
+ @env = env.to_sym
10
+ @user = user
11
+ @attributes = attributes
12
+ end
13
+
14
+ def matches?(target)
15
+ return false if target[:env] && env != target[:env].to_sym
16
+
17
+ if target[:hostname]
18
+ case target[:hostname]
19
+ when Regexp
20
+ return false unless hostname.match?(target[:hostname])
21
+ when String
22
+ return false unless hostname == target[:hostname]
23
+ end
24
+ end
25
+
26
+ true
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ "hostname" => hostname
32
+ }.merge(attributes.transform_keys(&:to_s))
33
+ end
34
+
35
+ def to_s
36
+ "#{user}@#{hostname} (#{env})"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/ssh"
4
+
5
+ module SimpleInfrastructure
6
+ class SshConnection
7
+ attr_reader :server
8
+
9
+ def initialize(server)
10
+ @server = server
11
+ @ssh = nil
12
+ end
13
+
14
+ def connect
15
+ @ssh = Net::SSH.start(
16
+ server.hostname,
17
+ server.user,
18
+ forward_agent: true,
19
+ verify_host_key: :accept_new
20
+ )
21
+ self
22
+ end
23
+
24
+ def disconnect
25
+ @ssh&.close
26
+ @ssh = nil
27
+ end
28
+
29
+ def exec(command)
30
+ stdout = ""
31
+ stderr = ""
32
+ exit_code = nil
33
+
34
+ @ssh.open_channel do |channel|
35
+ channel.exec(command) do |ch, success|
36
+ raise "Failed to execute command" unless success
37
+
38
+ ch.on_data { |_, data| stdout += data }
39
+ ch.on_extended_data { |_, _, data| stderr += data }
40
+ ch.on_request("exit-status") { |_, data| exit_code = data.read_long }
41
+ end
42
+ end.wait
43
+
44
+ {
45
+ stdout: stdout,
46
+ stderr: stderr,
47
+ exit_code: exit_code,
48
+ success: exit_code.zero?
49
+ }
50
+ end
51
+
52
+ def read_file(path, sudo: false)
53
+ cmd = "cat #{shell_escape(path)} 2>/dev/null || echo ''"
54
+ cmd = "sudo #{cmd}" if sudo
55
+ result = exec(cmd)
56
+ result[:stdout]
57
+ end
58
+
59
+ def write_file(path, content, sudo: false)
60
+ # Use heredoc to safely write content
61
+ delimiter = "INFRASTRUCTURE_EOF_#{rand(100_000)}"
62
+ cmd = "cat > #{shell_escape(path)} << '#{delimiter}'\n#{content}\n#{delimiter}"
63
+ if sudo
64
+ # For sudo, write to temp file then move
65
+ tmp = "/tmp/infrastructure_#{rand(100_000)}"
66
+ exec("cat > #{tmp} << '#{delimiter}'\n#{content}\n#{delimiter}")
67
+ exec("sudo mv #{tmp} #{shell_escape(path)}")
68
+ else
69
+ exec(cmd)
70
+ end
71
+ end
72
+
73
+ def file_exists?(path, sudo: false)
74
+ cmd = "test -f #{shell_escape(path)}"
75
+ cmd = "sudo #{cmd}" if sudo
76
+ result = exec(cmd)
77
+ result[:success]
78
+ end
79
+
80
+ private
81
+
82
+ def shell_escape(str)
83
+ # Handle ~ expansion: don't quote the ~/ prefix
84
+ if str.start_with?("~/")
85
+ "~/'#{str[2..].gsub("'", "'\\''")}'"
86
+ else
87
+ "'#{str.gsub("'", "'\\''")}'"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleInfrastructure
4
+ class TomlOperations
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
+ data = content.empty? ? {} : parse_toml(content)
15
+ modified = false
16
+
17
+ operations.each do |op, *args|
18
+ case op
19
+ when :set
20
+ key, value = args
21
+ current = deep_get(data, key)
22
+ if current != value
23
+ SimpleInfrastructure.logger.debug " #{key}: #{current.inspect} -> #{value.inspect}"
24
+ deep_set(data, key, value)
25
+ modified = true
26
+ end
27
+ when :remove
28
+ key = args[0]
29
+ if deep_has?(data, key)
30
+ SimpleInfrastructure.logger.debug " remove: #{key}"
31
+ deep_remove(data, key)
32
+ modified = true
33
+ end
34
+ end
35
+ end
36
+
37
+ if modified
38
+ new_content = dump_toml(data)
39
+ unless @dry_run
40
+ @connection.write_file(@path, new_content, sudo: @sudo)
41
+ end
42
+ else
43
+ SimpleInfrastructure.logger.debug " (no changes needed)"
44
+ end
45
+
46
+ true
47
+ end
48
+
49
+ private
50
+
51
+ def parse_toml(content)
52
+ data = {}
53
+ current_section = data
54
+
55
+ content.each_line do |line|
56
+ line = line.strip
57
+ next if line.empty? || line.start_with?("#")
58
+
59
+ if line =~ /^\[([^\]]+)\]$/
60
+ # Section header
61
+ current_section = deep_ensure(data, ::Regexp.last_match(1))
62
+ elsif line =~ /^([^=]+)=(.*)$/
63
+ key = ::Regexp.last_match(1).strip
64
+ value = parse_toml_value(::Regexp.last_match(2).strip)
65
+ current_section[key] = value
66
+ end
67
+ end
68
+
69
+ data
70
+ end
71
+
72
+ def parse_toml_value(str)
73
+ case str
74
+ when /^"(.*)"$/, /^'(.*)'$/ then ::Regexp.last_match(1)
75
+ when /^true$/i then true
76
+ when /^false$/i then false
77
+ when /^-?\d+$/ then str.to_i
78
+ when /^-?\d+\.\d+$/ then str.to_f
79
+ when /^\[(.*)\]$/
80
+ ::Regexp.last_match(1).split(",").map { |v| parse_toml_value(v.strip) }
81
+ else
82
+ str
83
+ end
84
+ end
85
+
86
+ def dump_toml(data, prefix = nil)
87
+ simple_keys = []
88
+ table_keys = []
89
+
90
+ data.each do |key, value|
91
+ if value.is_a?(Hash)
92
+ table_keys << key
93
+ else
94
+ simple_keys << key
95
+ end
96
+ end
97
+
98
+ # Output simple key=value pairs first
99
+ lines = simple_keys.map do |key|
100
+ "#{key} = #{toml_value(data[key])}"
101
+ end
102
+
103
+ # Then tables
104
+ table_keys.each do |key|
105
+ full_key = prefix ? "#{prefix}.#{key}" : key
106
+ lines << ""
107
+ lines << "[#{full_key}]"
108
+ lines << dump_toml(data[key], full_key)
109
+ end
110
+
111
+ lines.join("\n")
112
+ end
113
+
114
+ def toml_value(value)
115
+ case value
116
+ when String then "\"#{value}\""
117
+ when true, false, Integer, Float then value.to_s
118
+ when Array then "[#{value.map { |v| toml_value(v) }.join(', ')}]"
119
+ else value.to_s.inspect
120
+ end
121
+ end
122
+
123
+ def deep_get(hash, key_path)
124
+ keys = key_path.split(".")
125
+ keys.reduce(hash) { |h, k| h.is_a?(Hash) ? h[k] : nil }
126
+ end
127
+
128
+ def deep_set(hash, key_path, value)
129
+ keys = key_path.split(".")
130
+ last_key = keys.pop
131
+ target = keys.reduce(hash) { |h, k| h[k] ||= {} }
132
+ target[last_key] = value
133
+ end
134
+
135
+ def deep_has?(hash, key_path)
136
+ keys = key_path.split(".")
137
+ last_key = keys.pop
138
+ target = keys.reduce(hash) { |h, k| h.is_a?(Hash) ? h[k] : nil }
139
+ target.is_a?(Hash) && target.key?(last_key)
140
+ end
141
+
142
+ def deep_remove(hash, key_path)
143
+ keys = key_path.split(".")
144
+ last_key = keys.pop
145
+ target = keys.reduce(hash) { |h, k| h.is_a?(Hash) ? h[k] : nil }
146
+ target.delete(last_key) if target.is_a?(Hash)
147
+ end
148
+
149
+ def deep_ensure(hash, key_path)
150
+ keys = key_path.split(".")
151
+ keys.reduce(hash) { |h, k| h[k] ||= {} }
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleInfrastructure
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module SimpleInfrastructure
6
+ class YamlOperations
7
+ def initialize(connection, path, sudo: false, dry_run: false)
8
+ @connection = connection
9
+ @path = path
10
+ @sudo = sudo
11
+ @dry_run = dry_run
12
+ end
13
+
14
+ def apply(operations)
15
+ content = @connection.read_file(@path, sudo: @sudo)
16
+ data = content.empty? ? {} : YAML.safe_load(content) || {}
17
+ modified = false
18
+
19
+ operations.each do |op, *args|
20
+ case op
21
+ when :set
22
+ key, value = args
23
+ current = deep_get(data, key)
24
+ if current != value
25
+ SimpleInfrastructure.logger.debug " #{key}: #{current.inspect} -> #{value.inspect}"
26
+ deep_set(data, key, value)
27
+ modified = true
28
+ end
29
+ when :remove
30
+ key = args[0]
31
+ if deep_has?(data, key)
32
+ SimpleInfrastructure.logger.debug " remove: #{key}"
33
+ deep_remove(data, key)
34
+ modified = true
35
+ end
36
+ end
37
+ end
38
+
39
+ if modified
40
+ new_content = YAML.dump(data)
41
+ unless @dry_run
42
+ @connection.write_file(@path, new_content, sudo: @sudo)
43
+ end
44
+ else
45
+ SimpleInfrastructure.logger.debug " (no changes needed)"
46
+ end
47
+
48
+ true
49
+ end
50
+
51
+ private
52
+
53
+ def deep_get(hash, key_path)
54
+ keys = key_path.split(".")
55
+ keys.reduce(hash) { |h, k| h.is_a?(Hash) ? h[k] : nil }
56
+ end
57
+
58
+ def deep_set(hash, key_path, value)
59
+ keys = key_path.split(".")
60
+ last_key = keys.pop
61
+ target = keys.reduce(hash) { |h, k| h[k] ||= {} }
62
+ target[last_key] = value
63
+ end
64
+
65
+ def deep_has?(hash, key_path)
66
+ keys = key_path.split(".")
67
+ last_key = keys.pop
68
+ target = keys.reduce(hash) { |h, k| h.is_a?(Hash) ? h[k] : nil }
69
+ target.is_a?(Hash) && target.key?(last_key)
70
+ end
71
+
72
+ def deep_remove(hash, key_path)
73
+ keys = key_path.split(".")
74
+ last_key = keys.pop
75
+ target = keys.reduce(hash) { |h, k| h.is_a?(Hash) ? h[k] : nil }
76
+ target.delete(last_key) if target.is_a?(Hash)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "erb"
5
+ require "net/ssh"
6
+ require "fileutils"
7
+
8
+ require_relative "simple_infrastructure/version"
9
+ require_relative "simple_infrastructure/configuration"
10
+
11
+ module SimpleInfrastructure
12
+ autoload :Change, "simple_infrastructure/change"
13
+ autoload :RunStep, "simple_infrastructure/change"
14
+ autoload :FileStep, "simple_infrastructure/change"
15
+ autoload :OnChangeContext, "simple_infrastructure/change"
16
+ autoload :YamlStep, "simple_infrastructure/change"
17
+ autoload :TomlStep, "simple_infrastructure/change"
18
+ autoload :UploadStep, "simple_infrastructure/change"
19
+ autoload :Server, "simple_infrastructure/server"
20
+ autoload :Inventory, "simple_infrastructure/inventory"
21
+ autoload :Runner, "simple_infrastructure/runner"
22
+ autoload :Dsl, "simple_infrastructure/dsl"
23
+ autoload :FileOperations, "simple_infrastructure/file_operations"
24
+ autoload :YamlOperations, "simple_infrastructure/yaml_operations"
25
+ autoload :TomlOperations, "simple_infrastructure/toml_operations"
26
+ autoload :SshConnection, "simple_infrastructure/ssh_connection"
27
+ autoload :DryRunConnection, "simple_infrastructure/dry_run_connection"
28
+ autoload :Cli, "simple_infrastructure/cli"
29
+
30
+ class << self
31
+ def configuration
32
+ @configuration ||= Configuration.new
33
+ end
34
+
35
+ def configure
36
+ yield(configuration)
37
+ end
38
+
39
+ def logger
40
+ configuration.logger
41
+ end
42
+
43
+ def root
44
+ configuration.config_dir
45
+ end
46
+
47
+ def project_root
48
+ configuration.project_root
49
+ end
50
+
51
+ def reset_configuration!
52
+ @configuration = Configuration.new
53
+ end
54
+ end
55
+ end
56
+
57
+ require "simple_infrastructure/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :infrastructure do
4
+ desc "Show status of all servers"
5
+ task status: :environment do
6
+ require "simple_infrastructure"
7
+ SimpleInfrastructure::Runner.new.status
8
+ end
9
+
10
+ desc "Run pending changes on environment (e.g., rake infrastructure:run[production])"
11
+ task :run, [:target] => :environment do |_t, args|
12
+ require "simple_infrastructure"
13
+ target = args[:target] || ENV["TARGET"]
14
+ abort "Usage: rake infrastructure:run[production] or TARGET=production rake infrastructure:run" unless target
15
+
16
+ runner = SimpleInfrastructure::Runner.new
17
+ success = if target.include?(".")
18
+ runner.run_for_hostname(target)
19
+ else
20
+ runner.run_for_env(target)
21
+ end
22
+ exit(1) unless success
23
+ end
24
+
25
+ desc "Dry run pending changes (e.g., rake infrastructure:dry_run[production])"
26
+ task :dry_run, [:target] => :environment do |_t, args|
27
+ require "simple_infrastructure"
28
+ target = args[:target] || ENV["TARGET"]
29
+ abort "Usage: rake infrastructure:dry_run[production]" unless target
30
+
31
+ puts "=== DRY RUN MODE ===\n\n"
32
+ runner = SimpleInfrastructure::Runner.new(dry_run: true)
33
+ if target.include?(".")
34
+ runner.run_for_hostname(target)
35
+ else
36
+ runner.run_for_env(target)
37
+ end
38
+ end
39
+
40
+ desc "Generate a new change (e.g., rake infrastructure:generate[setup_redis])"
41
+ task :generate, [:name] => :environment do |_t, args|
42
+ require "simple_infrastructure"
43
+ name = args[:name]
44
+ abort "Usage: rake infrastructure:generate[change_name]" unless name
45
+
46
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S")
47
+ filename = "#{timestamp}_#{name}.rb"
48
+ path = File.join(SimpleInfrastructure.configuration.changes_dir, filename)
49
+
50
+ FileUtils.mkdir_p(File.dirname(path))
51
+
52
+ template = <<~RUBY
53
+ target env: :production
54
+
55
+ # run "command", sudo: true
56
+
57
+ # file "/path/to/file", sudo: true do
58
+ # contains "line that must exist"
59
+ # remove "line that must not exist"
60
+ # on_change { run "systemctl restart service", sudo: true }
61
+ # end
62
+
63
+ # yaml "/path/to/config.yml", sudo: true do
64
+ # set "key.path", "value"
65
+ # remove "old.key"
66
+ # end
67
+
68
+ # toml "/path/to/config.toml", sudo: true do
69
+ # set "key.path", "value"
70
+ # remove "old.key"
71
+ # end
72
+
73
+ # upload "config/backup/script.sh", "/root/bin/script.sh", sudo: true, mode: "700"
74
+ RUBY
75
+
76
+ File.write(path, template)
77
+ puts "Created: #{path}"
78
+ end
79
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: simple_infrastructure
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - FounderCatalyst
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: net-ssh
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ description: Simple Infrastructure provides a change-based approach to server configuration,
41
+ similar to how Rails database migrations work. Each change is tracked as a versioned
42
+ file, ensuring idempotency, auditability, and consistency across servers.
43
+ email:
44
+ - dev@foundercatalyst.com
45
+ executables:
46
+ - simple_infrastructure
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - bin/simple_infrastructure
54
+ - lib/generators/simple_infrastructure/change_generator.rb
55
+ - lib/generators/simple_infrastructure/templates/change.rb.tt
56
+ - lib/simple_infrastructure.rb
57
+ - lib/simple_infrastructure/change.rb
58
+ - lib/simple_infrastructure/cli.rb
59
+ - lib/simple_infrastructure/configuration.rb
60
+ - lib/simple_infrastructure/dry_run_connection.rb
61
+ - lib/simple_infrastructure/dsl.rb
62
+ - lib/simple_infrastructure/file_operations.rb
63
+ - lib/simple_infrastructure/inventory.rb
64
+ - lib/simple_infrastructure/railtie.rb
65
+ - lib/simple_infrastructure/runner.rb
66
+ - lib/simple_infrastructure/server.rb
67
+ - lib/simple_infrastructure/ssh_connection.rb
68
+ - lib/simple_infrastructure/toml_operations.rb
69
+ - lib/simple_infrastructure/version.rb
70
+ - lib/simple_infrastructure/yaml_operations.rb
71
+ - lib/tasks/simple_infrastructure.rake
72
+ homepage: https://github.com/foundercatalyst/simple_infrastructure
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ homepage_uri: https://github.com/foundercatalyst/simple_infrastructure
77
+ source_code_uri: https://github.com/foundercatalyst/simple_infrastructure
78
+ changelog_uri: https://github.com/foundercatalyst/simple_infrastructure/blob/main/CHANGELOG.md
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '3.1'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 4.0.3
94
+ specification_version: 4
95
+ summary: A migration-like DSL for server provisioning via SSH
96
+ test_files: []