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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bb8f4aec9834f672b6d225487853b1e935d642b54fc1e6c68f2cf9de94261174
4
+ data.tar.gz: 0a954b0c4d971c07e47840a99aacbbdcc9022a02d2338eb40c722a475b99866c
5
+ SHA512:
6
+ metadata.gz: 494eaf07fe0d6eefb7c6e2f1d8a2750b3ac7c22ef936dcaab9c91722534e9163f0333efac7654c5bb017afd792df19131f372322d0a19002a6789bc591512051
7
+ data.tar.gz: 23df4ef7dc6bc28d64a271d7ff83dd20965cd4ee6bfec7ad458360fd0f1fbe999fdae677a012fd3e903232280171b9cd46ace2ffd916d191944bcbcad9b16f23
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2025-03-03
4
+
5
+ - Initial extraction from FounderCatalyst Rails app
6
+ - Migration-like DSL for server provisioning via SSH
7
+ - Change types: `run`, `file`, `yaml`, `toml`, `upload`
8
+ - Server inventory with YAML/ERB support
9
+ - CLI with status, dry-run, and change generation
10
+ - Rails integration via Railtie (auto-configures project_root and logger)
11
+ - Rake tasks under `infrastructure:` namespace
12
+ - Rails generator for new change files
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 FounderCatalyst
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,301 @@
1
+ # Simple Infrastructure
2
+
3
+ A migration-like DSL for server provisioning via SSH. Provides a change-based approach to server configuration, similar to how Rails database migrations work but for infrastructure.
4
+
5
+ ## Features
6
+
7
+ - **Idempotency**: Changes can be run multiple times safely
8
+ - **Auditability**: All changes are tracked in version control
9
+ - **Consistency**: All servers in an environment receive the same configuration
10
+ - **Incremental updates**: Only pending changes are applied
11
+ - **Change tracking**: State stored on each server, automatically re-applied on rebuild
12
+
13
+ ## Installation
14
+
15
+ Add to your Gemfile:
16
+
17
+ ```ruby
18
+ gem "simple_infrastructure"
19
+ ```
20
+
21
+ Then run:
22
+
23
+ ```bash
24
+ bundle install
25
+ ```
26
+
27
+ For Rails apps, the gem auto-configures via Railtie (sets `project_root` to `Rails.root` and `logger` to `Rails.logger`).
28
+
29
+ For standalone use:
30
+
31
+ ```ruby
32
+ require "simple_infrastructure"
33
+
34
+ SimpleInfrastructure.configure do |config|
35
+ config.project_root = "/path/to/project"
36
+ end
37
+ ```
38
+
39
+ ## Directory Structure
40
+
41
+ ```
42
+ config/infrastructure/
43
+ ├── inventory.yml # Server inventory
44
+ └── changes/ # Change files
45
+ ├── 20250121000100_install_essentials.rb
46
+ ├── 20250121000200_configure_storage.rb
47
+ └── ...
48
+ ```
49
+
50
+ ## Server Inventory
51
+
52
+ Servers are defined in `config/infrastructure/inventory.yml`, grouped by environment:
53
+
54
+ ```yaml
55
+ defaults:
56
+ user: civo
57
+
58
+ servers:
59
+ production:
60
+ - hostname: web1.production.example.com
61
+ - hostname: web2.production.example.com
62
+ - hostname: db.production.example.com
63
+
64
+ staging:
65
+ - hostname: app1.staging.example.com
66
+ ```
67
+
68
+ The `defaults` section provides default values for all servers. Each server entry can override these defaults.
69
+
70
+ ## CLI Usage
71
+
72
+ ### Check Status
73
+
74
+ View change status for all servers:
75
+
76
+ ```bash
77
+ bin/infrastructure status
78
+ ```
79
+
80
+ Output shows which servers have pending changes:
81
+
82
+ ```
83
+ Production -------------------------------------------------------------
84
+ ✗ web1.production.example.com (6 pending)
85
+ ✔︎ web2.production.example.com (up to date)
86
+
87
+ Staging ----------------------------------------------------------------
88
+ ✗ app1.staging.example.com (2 pending)
89
+ ```
90
+
91
+ Use `-v` or `--verbose` to see the list of pending changes:
92
+
93
+ ```bash
94
+ bin/infrastructure status -v
95
+ ```
96
+
97
+ ### Dry Run
98
+
99
+ Preview what changes would be made without executing them:
100
+
101
+ ```bash
102
+ bin/infrastructure --dry-run staging
103
+ bin/infrastructure --dry-run production
104
+ bin/infrastructure --dry-run web1.production.example.com
105
+ ```
106
+
107
+ ### Run Changes
108
+
109
+ Apply pending changes:
110
+
111
+ ```bash
112
+ # All servers in an environment
113
+ bin/infrastructure staging
114
+ bin/infrastructure production
115
+
116
+ # Specific server
117
+ bin/infrastructure web1.production.example.com
118
+ ```
119
+
120
+ ### Generate New Change
121
+
122
+ Create a new change file:
123
+
124
+ ```bash
125
+ bin/infrastructure new setup_redis
126
+
127
+ # Or using the Rails generator:
128
+ rails generate simple_infrastructure:change setup_redis
129
+ ```
130
+
131
+ This creates a timestamped file like `config/infrastructure/changes/20250121143000_setup_redis.rb`.
132
+
133
+ ## Writing Changes
134
+
135
+ ### Basic Structure
136
+
137
+ ```ruby
138
+ # Target specific servers (omit target line for all servers)
139
+ target env: :production # All production servers
140
+ target env: :staging # All staging servers
141
+ target hostname: "db.production.example.com" # Specific server
142
+
143
+ # Run shell commands
144
+ run "apt update", sudo: true
145
+ run "systemctl restart nginx", sudo: true
146
+
147
+ # Manage file contents
148
+ file "/etc/ssh/sshd_config", sudo: true do
149
+ contains "PermitRootLogin no" # Ensure line exists
150
+ remove "PermitRootLogin yes" # Remove line if present
151
+ on_change { run "systemctl restart sshd", sudo: true }
152
+ end
153
+
154
+ # Manage YAML files
155
+ yaml "/etc/config.yml", sudo: true do
156
+ set "server.port", 8080
157
+ remove "deprecated.setting"
158
+ end
159
+
160
+ # Manage TOML files
161
+ toml "/etc/config.toml", sudo: true do
162
+ set "database.host", "localhost"
163
+ end
164
+
165
+ # Upload local files
166
+ upload "config/backup/script.sh", "/root/bin/script.sh", sudo: true, mode: "700"
167
+ ```
168
+
169
+ ### Targeting Servers
170
+
171
+ By default, changes apply to all servers. Use `target` to restrict to specific servers:
172
+
173
+ ```ruby
174
+ # All servers in production
175
+ target env: :production
176
+
177
+ # Specific hostname
178
+ target hostname: "db.production.example.com"
179
+
180
+ # Hostname pattern (regex)
181
+ target hostname: /^web\d+\.production\./
182
+ ```
183
+
184
+ ### DSL Reference
185
+
186
+ #### `run(command, sudo: false)`
187
+
188
+ Execute a shell command on the remote server.
189
+
190
+ ```ruby
191
+ run "apt update", sudo: true
192
+ run "docker compose up -d"
193
+ ```
194
+
195
+ #### `file(path, sudo: false, &block)`
196
+
197
+ Manage plain text file contents.
198
+
199
+ ```ruby
200
+ file "/etc/fstab", sudo: true do
201
+ contains "/swapfile none swap sw 0 0" # Add if missing
202
+ remove "/old/swap none swap sw 0 0" # Remove if present
203
+ on_change { run "mount -a", sudo: true } # Run only if file changed
204
+ end
205
+ ```
206
+
207
+ #### `yaml(path, sudo: false, &block)`
208
+
209
+ Manage YAML configuration files.
210
+
211
+ ```ruby
212
+ yaml "/etc/app/config.yml", sudo: true do
213
+ set "database.host", "localhost"
214
+ set "database.port", 3306
215
+ remove "deprecated_key"
216
+ end
217
+ ```
218
+
219
+ #### `toml(path, sudo: false, &block)`
220
+
221
+ Manage TOML configuration files.
222
+
223
+ ```ruby
224
+ toml "/etc/app/config.toml", sudo: true do
225
+ set "server.bind", "0.0.0.0"
226
+ set "server.port", 8080
227
+ end
228
+ ```
229
+
230
+ #### `upload(local_path, remote_path, sudo: false, mode: nil)`
231
+
232
+ Upload a local file to the remote server.
233
+
234
+ ```ruby
235
+ upload "bin/backup", "/root/bin/backup", sudo: true, mode: "700"
236
+ upload "config/nginx.conf", "/etc/nginx/nginx.conf", sudo: true
237
+ ```
238
+
239
+ ### The `on_change` Callback
240
+
241
+ The `file` DSL supports an `on_change` callback that only executes when the file was actually modified:
242
+
243
+ ```ruby
244
+ file "/etc/ssh/sshd_config", sudo: true do
245
+ remove "PermitRootLogin yes"
246
+ contains "PermitRootLogin no"
247
+ on_change { run "systemctl restart sshd", sudo: true }
248
+ end
249
+ ```
250
+
251
+ This is useful for restarting services only when their configuration changes, avoiding unnecessary service restarts.
252
+
253
+ ## Rake Tasks
254
+
255
+ Alternative to the CLI:
256
+
257
+ ```bash
258
+ # Show status
259
+ rake infrastructure:status
260
+
261
+ # Run changes
262
+ rake infrastructure:run[production]
263
+ rake infrastructure:run[staging]
264
+
265
+ # Dry run
266
+ rake infrastructure:dry_run[production]
267
+
268
+ # Generate change
269
+ rake infrastructure:generate[setup_redis]
270
+ ```
271
+
272
+ ## Change Tracking
273
+
274
+ The system tracks which changes have been applied by storing log files on each server in `~/.infrastructure/`:
275
+
276
+ ```
277
+ ~/.infrastructure/
278
+ ├── 20250121000100_install_essentials.log
279
+ ├── 20250121000200_configure_storage.log
280
+ ├── 20250121000300_configure_swap.log
281
+ └── ...
282
+ ```
283
+
284
+ Each log file contains the timestamp when the change was applied and any output from the commands.
285
+
286
+ This approach means:
287
+ - State lives on the server itself, not in your local repository
288
+ - If a server is deleted and recreated, changes will be re-applied automatically
289
+ - Log files provide debugging information if something goes wrong
290
+
291
+ ## Best Practices
292
+
293
+ 1. **Make changes idempotent**: Use `contains` instead of blindly appending, check if files exist before creating them
294
+ 2. **Use `on_change` for service restarts**: Avoid unnecessary restarts by only restarting when config actually changes
295
+ 3. **Test with dry-run first**: Always preview changes before applying to production
296
+ 4. **Target narrowly when appropriate**: Use specific hostnames for server-specific configuration (like database backups)
297
+ 5. **Keep changes small and focused**: One concern per change makes troubleshooting easier
298
+
299
+ ## License
300
+
301
+ MIT License. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "simple_infrastructure"
5
+
6
+ SimpleInfrastructure::Cli.new(ARGV).run
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module SimpleInfrastructure
6
+ module Generators
7
+ class ChangeGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ def create_change_file
11
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S")
12
+ template "change.rb.tt", "config/infrastructure/changes/#{timestamp}_#{file_name}.rb"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ target env: :production
2
+
3
+ # run "command", sudo: true
4
+
5
+ # file "/path/to/file", sudo: true do
6
+ # contains "line that must exist"
7
+ # remove "line that must not exist"
8
+ # on_change { run "systemctl restart service", sudo: true }
9
+ # end
10
+
11
+ # yaml "/path/to/config.yml", sudo: true do
12
+ # set "key.path", "value"
13
+ # remove "old.key"
14
+ # end
15
+
16
+ # toml "/path/to/config.toml", sudo: true do
17
+ # set "key.path", "value"
18
+ # remove "old.key"
19
+ # end
20
+
21
+ # upload "config/backup/script.sh", "/root/bin/script.sh", sudo: true, mode: "700"
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleInfrastructure
4
+ class Change
5
+ attr_reader :name, :target_criteria, :steps
6
+
7
+ def initialize(name)
8
+ @name = name
9
+ @target_criteria = {}
10
+ @steps = []
11
+ end
12
+
13
+ def target(criteria)
14
+ @target_criteria = criteria
15
+ end
16
+
17
+ def targets?(server)
18
+ server.matches?(target_criteria)
19
+ end
20
+
21
+ def add_step(step)
22
+ @steps << step
23
+ end
24
+
25
+ def <=>(other)
26
+ name <=> other.name
27
+ end
28
+
29
+ def self.load_all
30
+ changes_dir = SimpleInfrastructure.configuration.changes_dir
31
+ return [] unless Dir.exist?(changes_dir)
32
+
33
+ Dir.glob(File.join(changes_dir, "*.rb")).map do |file|
34
+ Dsl.load_file(file)
35
+ end
36
+ end
37
+ end
38
+
39
+ # Step types
40
+ class RunStep
41
+ attr_reader :command, :sudo
42
+
43
+ def initialize(command, sudo: false)
44
+ @command = command
45
+ @sudo = sudo
46
+ end
47
+
48
+ def execute(connection, dry_run: false)
49
+ full_command = sudo ? "sudo #{command}" : command
50
+ SimpleInfrastructure.logger.debug " RUN: #{full_command}"
51
+ return true if dry_run
52
+
53
+ result = connection.exec(full_command)
54
+ unless result[:success]
55
+ raise "Command failed with exit code #{result[:exit_code]}: #{result[:stderr]}"
56
+ end
57
+
58
+ true
59
+ end
60
+ end
61
+
62
+ class FileStep
63
+ attr_reader :path, :operations, :sudo, :on_change_callback
64
+
65
+ def initialize(path, sudo: false)
66
+ @path = path
67
+ @operations = []
68
+ @sudo = sudo
69
+ @on_change_callback = nil
70
+ end
71
+
72
+ def contains(line)
73
+ @operations << [:contains, line]
74
+ end
75
+
76
+ def remove(line)
77
+ @operations << [:remove, line]
78
+ end
79
+
80
+ def on_change(&block)
81
+ @on_change_callback = block
82
+ end
83
+
84
+ def execute(connection, dry_run: false)
85
+ SimpleInfrastructure.logger.debug " FILE: #{path}#{' (sudo)' if sudo}"
86
+ modified = FileOperations.new(connection, path, sudo: sudo, dry_run: dry_run).apply(@operations)
87
+
88
+ if modified && @on_change_callback && !dry_run
89
+ SimpleInfrastructure.logger.debug " ON_CHANGE: Executing callback..."
90
+ callback_context = OnChangeContext.new(connection)
91
+ callback_context.instance_eval(&@on_change_callback)
92
+ end
93
+
94
+ true
95
+ end
96
+ end
97
+
98
+ class OnChangeContext
99
+ def initialize(connection)
100
+ @connection = connection
101
+ end
102
+
103
+ def run(command, sudo: false)
104
+ full_command = sudo ? "sudo #{command}" : command
105
+ SimpleInfrastructure.logger.debug " RUN: #{full_command}"
106
+ result = @connection.exec(full_command)
107
+ unless result[:success]
108
+ raise "Command failed with exit code #{result[:exit_code]}: #{result[:stderr]}"
109
+ end
110
+
111
+ true
112
+ end
113
+ end
114
+
115
+ class YamlStep
116
+ attr_reader :path, :operations, :sudo
117
+
118
+ def initialize(path, sudo: false)
119
+ @path = path
120
+ @operations = []
121
+ @sudo = sudo
122
+ end
123
+
124
+ def set(key, value)
125
+ @operations << [:set, key, value]
126
+ end
127
+
128
+ def remove(key)
129
+ @operations << [:remove, key]
130
+ end
131
+
132
+ def execute(connection, dry_run: false)
133
+ SimpleInfrastructure.logger.debug " YAML: #{path}#{' (sudo)' if sudo}"
134
+ YamlOperations.new(connection, path, sudo: sudo, dry_run: dry_run).apply(@operations)
135
+ end
136
+ end
137
+
138
+ class TomlStep
139
+ attr_reader :path, :operations, :sudo
140
+
141
+ def initialize(path, sudo: false)
142
+ @path = path
143
+ @operations = []
144
+ @sudo = sudo
145
+ end
146
+
147
+ def set(key, value)
148
+ @operations << [:set, key, value]
149
+ end
150
+
151
+ def remove(key)
152
+ @operations << [:remove, key]
153
+ end
154
+
155
+ def execute(connection, dry_run: false)
156
+ SimpleInfrastructure.logger.debug " TOML: #{path}#{' (sudo)' if sudo}"
157
+ TomlOperations.new(connection, path, sudo: sudo, dry_run: dry_run).apply(@operations)
158
+ end
159
+ end
160
+
161
+ class UploadStep
162
+ attr_reader :local_path, :remote_path, :sudo, :mode
163
+
164
+ def initialize(local_path, remote_path, sudo: false, mode: nil)
165
+ @local_path = local_path
166
+ @remote_path = remote_path
167
+ @sudo = sudo
168
+ @mode = mode
169
+ end
170
+
171
+ def execute(connection, dry_run: false)
172
+ SimpleInfrastructure.logger.debug " UPLOAD: #{local_path} -> #{remote_path}#{' (sudo)' if sudo}"
173
+ return true if dry_run
174
+
175
+ content = File.read(File.join(SimpleInfrastructure.project_root, local_path))
176
+ connection.write_file(remote_path, content, sudo: sudo)
177
+
178
+ if mode
179
+ chmod_cmd = "chmod #{mode} #{remote_path}"
180
+ chmod_cmd = "sudo #{chmod_cmd}" if sudo
181
+ connection.exec(chmod_cmd)
182
+ end
183
+
184
+ true
185
+ end
186
+ end
187
+ end