├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console ├── prepper └── setup ├── examples ├── config.rb └── templates │ ├── myblog.caddy │ ├── myrails-site.com.caddy │ ├── myrails-site.service │ └── sudoers ├── lib ├── prepper.rb ├── prepper │ ├── cli.rb │ ├── command.rb │ ├── package.rb │ ├── runner.rb │ ├── tools │ │ ├── apt.rb │ │ ├── file.rb │ │ ├── rbenv.rb │ │ ├── text.rb │ │ └── users.rb │ └── version.rb └── sshkit_ext.rb ├── prepper.gemspec └── test ├── prepper └── runner_test.rb ├── prepper_test.rb └── test_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | # schedule: 8 | # - cron: '42 5 * * *' 9 | 10 | jobs: 11 | test: 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | ruby: [ '3.0' ] 16 | 17 | runs-on: ubuntu-latest 18 | name: Ruby ${{matrix.ruby}} 19 | container: ruby:${{matrix.ruby}} 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | 24 | - name: Show ruby Version 25 | run: | 26 | ruby -v 27 | 28 | - name: Install Modules 29 | run: ./bin/setup 30 | 31 | - name: Run tests 32 | run: rake test 33 | 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | cache: bundler 4 | rvm: 5 | - 2.7.2 6 | before_install: gem install bundler -v 2.1.4 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## Unreleased 4 | 5 | ## 0.2.0 6 | 7 | * better docs 8 | 9 | ## 0.1.0 10 | 11 | * First release 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at molnargerg@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in prepper.gemspec 4 | gemspec 5 | 6 | gem "rake", "~> 12.0" 7 | gem "minitest", "~> 5.0" 8 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | prepper (0.2.1) 5 | sshkit 6 | tty-option 7 | zeitwerk 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | byebug (11.1.3) 13 | minitest (5.16.3) 14 | net-scp (4.0.0) 15 | net-ssh (>= 2.6.5, < 8.0.0) 16 | net-ssh (7.0.1) 17 | rake (12.3.3) 18 | sshkit (1.21.3) 19 | net-scp (>= 1.1.2) 20 | net-ssh (>= 2.8.0) 21 | tty-option (0.2.0) 22 | zeitwerk (2.6.6) 23 | 24 | PLATFORMS 25 | ruby 26 | 27 | DEPENDENCIES 28 | byebug 29 | minitest (~> 5.0) 30 | prepper! 31 | rake (~> 12.0) 32 | 33 | BUNDLED WITH 34 | 2.1.4 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Greg Molnar 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prepper 2 | 3 | Prepper is a simple server provisioning tool, built on top of SSHKit. You can 4 | use it to script your server build process. It is heavily inspired by (Sprinkle)[https://github.com/sprinkle-tool/sprinkle], but Sprinkle doesn't seem to be maintained anymore, that's why Prepper was born. 5 | 6 | 7 | ## Installation 8 | 9 | $ gem install prepper 10 | 11 | ## Usage 12 | 13 | Prepper works with "packages". You define a package with a name and pass it a block. 14 | Within that block you can execute commands on the target host. 15 | There are built in helpers to install `apt` packages, manage directories and 16 | upload files to the server, etc. 17 | 18 | A simple example: 19 | 20 | ```ruby 21 | 22 | server_host "YOUR_SERVER_IP" 23 | server_port 22 24 | server_user "root" 25 | 26 | # let's install the necessary packages to run a Rails app with Postgresql 27 | package :apt do 28 | apt_update 29 | apt_install %w(git-core build-essential libcurl4 libcurl4-openssl-dev libjemalloc-dev postgresql-client libpq-dev postgresql-contrib) 30 | end 31 | 32 | # now we will add a deploy user 33 | package :add_deploy_user do 34 | add_user 'deploy', shell: '/bin/bash', flags: '--disabled-password' 35 | 36 | directory '/home/deploy/.ssh', owner: 'deploy:deploy' 37 | file '/home/deploy/.ssh/authorized_keys', owner: 'deploy:deploy', mode: '655', content: 'ssh-rsa YOUR PUBLIC SSH KEY' 38 | file '/etc/sudoers.d/deploy', owner: 'root:root', template: 'sudoers' 39 | end 40 | 41 | # install rbenv and Ruby 3.1.2 42 | package :install_ruby do 43 | install_rbenv 'deploy' 44 | install_ruby 'deploy', '3.1.2', '--with-jemalloc' 45 | end 46 | 47 | ``` 48 | 49 | You can see a full example in [examples/config.rb](examples/config.rb). You would run that file with `bundle exec prepper config.rb` to provision the server. 50 | 51 | The API documentation can be found on rubydoc: [https://rubydoc.info/github/gregmolnar/prepper](https://rubydoc.info/github/gregmolnar/prepper). 52 | 53 | ## Development 54 | 55 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 56 | 57 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 58 | 59 | ## Contributing 60 | 61 | Bug reports and pull requests are welcome on GitHub at https://github.com/gregmolnar/prepper. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/gregmolnar/prepper/blob/master/CODE_OF_CONDUCT.md). 62 | 63 | 64 | ## License 65 | 66 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 67 | 68 | ## Code of Conduct 69 | 70 | Everyone interacting in the Prepper project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/gregmolnar/prepper/blob/master/CODE_OF_CONDUCT.md). 71 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/*_test.rb"] 8 | end 9 | 10 | task :default => :test 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "prepper" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/prepper: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $:.unshift "#{File.expand_path(File.dirname(__FILE__))}/../lib" 3 | $VERBOSE = nil 4 | require 'prepper' 5 | 6 | Prepper::Cli.new.parse.run 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /examples/config.rb: -------------------------------------------------------------------------------- 1 | server_host "YOUR_SERVER_IP" 2 | server_port 22 3 | server_user "root" 4 | 5 | # let's install the necessary packages to run a Rails app with Postgresql 6 | package :apt do 7 | apt_update 8 | apt_install %w(git-core build-essential libcurl4 libcurl4-openssl-dev libjemalloc-dev postgresql-client libpq-dev postgresql-contrib) 9 | end 10 | 11 | # now we will add a deploy user 12 | package :add_deploy_user do 13 | add_user 'deploy', shell: '/bin/bash', flags: '--disabled-password' 14 | 15 | directory '/home/deploy/.ssh', owner: 'deploy:deploy' 16 | file '/home/deploy/.ssh/authorized_keys', owner: 'deploy:deploy', mode: '655', content: 'ssh-rsa YOUR PUBLIC SSH KEY' 17 | file '/etc/sudoers.d/deploy', owner: 'root:root', template: 'sudoers' 18 | end 19 | 20 | # install rbenv and Ruby 3.1.2 21 | package :install_ruby do 22 | install_rbenv 'deploy' 23 | install_ruby 'deploy', '3.1.2', '--with-jemalloc' 24 | end 25 | 26 | # install yarn 27 | package :yarn do 28 | add_command 'curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -' 29 | add_command 'echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list', 30 | verify: has_file?('/etc/apt/sources.list.d/yarn.list') 31 | apt_update 32 | apt_install %w(nodejs yarn) 33 | end 34 | 35 | # install the caddy webserver 36 | package :install_caddy do 37 | apt_install %w(debian-keyring debian-archive-keyring apt-transport-https) 38 | add_command "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg", verifier: has_file?('/etc/apt/sources.list.d/caddy-stable.list') 39 | add_command "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list", verifier: has_file?('/etc/apt/sources.list.d/caddy-stable.list') 40 | 41 | apt_update 42 | apt_install %w(caddy) 43 | directory "/etc/caddy/sites", owner: 'caddy:caddy' 44 | file "/etc/caddy/Caddyfile", content: "import /etc/caddy/sites/*.caddy", owner: 'caddy:caddy' 45 | file "/etc/caddy/sites/global.caddy", content: " 46 | { 47 | debug 48 | log { 49 | output file /var/log/caddy/caddy.log { 50 | roll_size 10MB 51 | } 52 | } 53 | } 54 | ", owner: 'caddy:caddy' 55 | chown '/etc/caddy/*', 'caddy:caddy' 56 | add_command 'sudo adduser caddy deploy' 57 | add_command 'sudo service caddy reload' 58 | end 59 | 60 | # create a static site 61 | package :my_blog_com do 62 | directory '/home/deploy/domains/myblog.com/public' 63 | chown '/home/deploy/domains/myblog.com', 'deploy:deploy', "-R" 64 | directory '/home/deploy/domains/myblog.com/shared/log', owner: "caddy:caddy" 65 | file '/etc/caddy/sites/myblog.com.caddy', template: 'myblog.com.caddy' 66 | chown '/etc/caddy/sites', 'caddy:caddy', "-R" 67 | add_command "sudo service caddy reload" 68 | end 69 | 70 | # create a vhost for a Rails site 71 | package :myrails_site_com do 72 | directory '/home/deploy/domains/myrails-site.com/' 73 | chown '/home/deploy/domains/', 'deploy:deploy' 74 | file '/etc/caddy/sites/myrails-site.com.caddy', template: 'myrails-site.com.caddy' 75 | chown '/etc/caddy/sites/*', 'caddy:caddy' 76 | add_command "sudo service caddy reload" 77 | end 78 | 79 | # create a systemd service for puma 80 | package :puma_myrails_site do 81 | directory "/home/deploy/.config/systemd/user/", user: 'deploy' 82 | chown "/home/deploy/.config", "deploy:deploy", "-R" 83 | file "/home/deploy/.config/systemd/user/puma_myrails-site.service", owner: 'deploy:deploy', template: 'puma_myrails-site.service' 84 | 85 | directory '/home/deploy/.config/systemd/user/default.target.wants', user: 'deploy' 86 | symlink "/home/deploy/.config/systemd/user/default.target.wants/puma_myrails-site.service", "/home/deploy/.config/systemd/user/puma_myrails-site.service", user: 'deploy' 87 | 88 | add_command "sudo -u deploy -l systemctl --user daemon-reload" 89 | add_command "sudo -u deploy -l systemctl --user enable puma_myrails-site" 90 | add_command "sudo loginctl enable-linger deploy" 91 | end 92 | -------------------------------------------------------------------------------- /examples/templates/myblog.caddy: -------------------------------------------------------------------------------- 1 | www.myblog.com { 2 | redir https://{host}{uri} 3 | } 4 | 5 | myblog.com { 6 | root * /home/deploy/domains/myblog.com/public 7 | 8 | 9 | log { 10 | output file /home/deploy/domains/myblog.com/shared/log/access.log { 11 | roll_size 10MB 12 | roll_keep 10 13 | } 14 | } 15 | 16 | encode zstd gzip 17 | 18 | file_server 19 | 20 | @notStatic { 21 | not file 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/templates/myrails-site.com.caddy: -------------------------------------------------------------------------------- 1 | www.myrails-site.com { 2 | redir https://{host}{uri} 3 | } 4 | 5 | myrails-site.com { 6 | root * /home/deploy/domains/myrails-site.com/current/public 7 | 8 | 9 | log { 10 | output file /home/deploy/domains/myrails-site.com/shared/log/access.log { 11 | roll_size 10MB 12 | roll_keep 10 13 | } 14 | } 15 | 16 | encode zstd gzip 17 | 18 | file_server 19 | 20 | @notStatic { 21 | not file 22 | } 23 | 24 | reverse_proxy @notStatic unix//home/deploy/domains/myrails-site.com/shared/tmp/sockets/puma.sock 25 | } 26 | -------------------------------------------------------------------------------- /examples/templates/myrails-site.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Puma HTTP Server for myrails-site.com (production) 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | WorkingDirectory=/home/deploy/domains/myrails-site.com/current 8 | # Support older bundler versions where file descriptors weren't kept 9 | # See https://github.com/rubygems/rubygems/issues/3254 10 | ExecStart=/home/deploy/.rbenv/bin/rbenv exec bundle exec --keep-file-descriptors puma -C /home/deploy/domains/myrails-site.com/shared/puma.rb 11 | ExecReload=/bin/kill -USR1 $MAINPID 12 | StandardOutput=append:/home/deploy/domains/myrails-site.com/shared/log/puma_access.log 13 | StandardError=append:/home/deploy/domains/myrails-site.com/shared/log/puma_error.log 14 | 15 | Restart=always 16 | RestartSec=1 17 | 18 | SyslogIdentifier=puma 19 | 20 | [Install] 21 | WantedBy=multi-user.target 22 | -------------------------------------------------------------------------------- /examples/templates/sudoers: -------------------------------------------------------------------------------- 1 | %deploy ALL= NOPASSWD: /bin/systemctl start puma_move_to_azores 2 | %deploy ALL= NOPASSWD: /bin/systemctl stop puma_move_to_azores 3 | %deploy ALL= NOPASSWD: /bin/systemctl restart puma_move_to_azores 4 | -------------------------------------------------------------------------------- /lib/prepper.rb: -------------------------------------------------------------------------------- 1 | require "zeitwerk" 2 | loader = Zeitwerk::Loader.for_gem 3 | loader.ignore("#{__dir__}/sshkit_ext.rb") 4 | loader.setup 5 | 6 | require 'sshkit_ext' 7 | require 'shellwords' 8 | 9 | module Prepper 10 | class Error < StandardError; end 11 | # Your code goes here... 12 | end 13 | -------------------------------------------------------------------------------- /lib/prepper/cli.rb: -------------------------------------------------------------------------------- 1 | require 'tty/option' 2 | module Prepper 3 | class Cli 4 | include TTY::Option 5 | usage do 6 | program 'Prepper' 7 | command 'run' 8 | desc 'provision your server' 9 | end 10 | 11 | argument :config_file do 12 | desc 'path to config file' 13 | end 14 | 15 | def run 16 | if params[:help] 17 | print help and exit 18 | else 19 | Prepper::Runner.run(File.read(params[:config_file])) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/prepper/command.rb: -------------------------------------------------------------------------------- 1 | module Prepper 2 | class Command 3 | attr_reader :command, :user, :within, :env, :sudo, :opts, :verifier 4 | 5 | def initialize(command, opts = {}) 6 | @command = command 7 | @opts = opts 8 | @user = opts[:user] || "root" 9 | @within = opts[:within] || "/" 10 | @env = opts[:env] || {} 11 | @sudo = opts[:sudo] || false 12 | @verifier = opts[:verifier] 13 | end 14 | 15 | def to_s 16 | if @sudo 17 | @command.dup.prepend("sudo ") 18 | else 19 | @command 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/prepper/package.rb: -------------------------------------------------------------------------------- 1 | module Prepper 2 | class Package 3 | include SSHKit::DSL 4 | include Tools::Apt 5 | include Tools::Users 6 | include Tools::File 7 | include Tools::Text 8 | include Tools::Rbenv 9 | 10 | attr_accessor :name, :runner, :commands, :verifications 11 | 12 | def initialize(name, opts = {}, &block) 13 | @name = name 14 | @opts = opts 15 | @runner = opts[:runner] 16 | @verifications = [] 17 | @commands = [] 18 | instance_eval &block if block_given? 19 | end 20 | 21 | def should_run? 22 | return true if @verifications.empty? 23 | return @verifications.all? do |verification| 24 | !test_command(verification.call) 25 | end 26 | end 27 | 28 | def verify(&block) 29 | @verifications << block 30 | end 31 | 32 | def process 33 | unless should_run? 34 | SSHKit.config.output.write(SSHKit::LogMessage.new(1, "Skipping package #{name}")) 35 | return 36 | end 37 | @commands.each do |command| 38 | if command.verifier 39 | if !test_command(command.verifier) 40 | execute_command(command) 41 | else 42 | SSHKit.config.output.write(SSHKit::LogMessage.new(1, "Skipping command #{command.to_s}")) 43 | end 44 | else 45 | execute_command(command) 46 | end 47 | end 48 | end 49 | 50 | def add_command(command, opts = {}) 51 | opts[:user] ||= "root" 52 | opts[:within] ||= "/" 53 | @commands << Command.new(command, opts) 54 | end 55 | 56 | def execute_command(command) 57 | run_command(:execute, command) 58 | end 59 | 60 | def test_command(command) 61 | run_command(:test, command) 62 | end 63 | 64 | def run_command(method, command) 65 | on [runner.server_hash], in: :sequence do |host| 66 | within command.within do 67 | as command.user do 68 | with command.env do 69 | if respond_to? command.to_s.to_sym 70 | send command.to_s.to_sym, *command.opts[:params] 71 | else 72 | if method == :execute 73 | execute command.to_s 74 | else 75 | 76 | send(method, command.to_s) 77 | end 78 | end 79 | end 80 | end 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/prepper/runner.rb: -------------------------------------------------------------------------------- 1 | require 'sshkit' 2 | require 'sshkit/dsl' 3 | module Prepper 4 | class Runner 5 | 6 | attr_accessor :host, :packages, :commands, :user, :port 7 | 8 | def self.run(config) 9 | runner = new(config) 10 | runner.run 11 | runner 12 | end 13 | 14 | def initialize(config) 15 | @packages = [] 16 | @commands = [] 17 | @user = "root" 18 | @port = 22 19 | instance_eval config 20 | end 21 | 22 | def run 23 | @packages.each(&:process) 24 | end 25 | 26 | def server_host(host) 27 | @host = host 28 | end 29 | 30 | def server_user(user) 31 | @user = user 32 | end 33 | 34 | def server_port(port) 35 | @port = port 36 | end 37 | 38 | def ssh_options(ssh_options) 39 | @ssh_options = ssh_options 40 | end 41 | 42 | def server_hash 43 | {hostname: host, user: user, port: port, ssh_options: @ssh_options} 44 | end 45 | 46 | def add_command(command, opts = {}) 47 | package = Package.new("base", opts) 48 | package.runner = self 49 | opts[:user] ||= "root" 50 | opts[:within] ||= "/" 51 | package.commands << Command.new(command, opts) 52 | @packages << package 53 | end 54 | 55 | def package(name, opts = {}, &block) 56 | @packages << Package.new(name, opts.merge(runner: self), &block) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/prepper/tools/apt.rb: -------------------------------------------------------------------------------- 1 | module Prepper 2 | module Tools 3 | # Helper methods to interact with Apt 4 | module Apt 5 | # Updates apt repositories 6 | def apt_update 7 | @commands << Command.new("apt update", sudo: true) 8 | end 9 | 10 | # Installs packages 11 | # @param packages [Array] array of package names 12 | def apt_install(packages) 13 | packages.each do |package| 14 | @commands << Command.new("apt install --force-yes -qyu #{package}", sudo: true, verify: has_apt_package?(package)) 15 | end 16 | end 17 | 18 | # Verifier command to checks if an apt package is installed 19 | # @param package [String] name of the package 20 | def has_apt_package?(package) 21 | Command.new("dpkg --status #{package} | grep 'ok installed'", sudo: true) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/prepper/tools/file.rb: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | require 'digest/md5' 3 | module Prepper 4 | module Tools 5 | # Helper methods for file and directory management 6 | module File 7 | 8 | # Changes ownership of a path 9 | # @param path [String] the path which we want to change the ownership of 10 | # @param owner [String] name of the owner, ie: 'root:root' 11 | # @param flags [String] flags to pass to chown, ie: '-R' to do it recursively 12 | def chown(path, owner, flags = "") 13 | @commands << Command.new("chown #{flags} #{owner} #{path}", sudo: true) 14 | end 15 | 16 | # Create a directory unless it already exists 17 | # @param path [String] path of the directory 18 | # @param [Hash] opts options hash 19 | # @option opts [String] :owner Owner of the directory, ie: 'root:root' 20 | # @option opts [String] :mode mode bits, ie: '0777' 21 | def directory(path, opts = {}) 22 | @commands << Command.new("mkdir -p #{path}", opts.merge(sudo: true, verifier: has_directory?(path))) 23 | @commands << Command.new("chown #{opts[:owner]} #{path}", sudo: true) if opts[:owner] 24 | @commands << Command.new("chmod #{opts[:mode]} #{path}", sudo: true) if opts[:mode] 25 | end 26 | 27 | # returns a verifier command to test if a directory exists 28 | # @param path [String] path to test 29 | def has_directory?(path) 30 | Command.new("test -d #{path}", sudo: true) 31 | end 32 | 33 | # Create a file unless it already exists. The contents can be set to a 34 | # string or a template can be rendered with the provided locals 35 | # @param path [String] path of the file 36 | # @param [Hash] opts options hash 37 | # @option opts [String] :content string content of the file 38 | # @option opts [String] :template name of the template for the file 39 | # @option opts [String] :locals hash of variables to pass to the template 40 | # @option opts [String] :verify_content set to true to verify the file 41 | # content is the same in case the file already exists 42 | # @option opts [String] :owner Owner of the directory, ie: 'root:root' 43 | # @option opts [String] :mode mode bits, ie: '0777' 44 | def file(path, opts = {}) 45 | opts[:locals] ||= {} 46 | opts[:verify_content] ||= true 47 | content = opts[:content] || render_template(opts[:template], opts[:locals]) 48 | verifier = if opts[:verify_content] 49 | matches_content?(path, content) 50 | else 51 | has_file?(path) 52 | end 53 | io = StringIO.new(content) 54 | @commands << Command.new("put!", {params: [io, path, {owner: opts[:owner], mode: opts[:mode]}], verifier: verifier}) 55 | end 56 | 57 | # returns a verifier command to test if a file exists 58 | # @param path [String] path to test 59 | def has_file?(path) 60 | Command.new("test -f #{path}", sudo: true) 61 | end 62 | 63 | # returns a verifier command to test if has a matching content 64 | # @param path [String] path to test 65 | # @param content [String] expected content 66 | def matches_content?(path, content) 67 | md5 = Digest::MD5.hexdigest(content) 68 | Command.new("md5sum #{path} | cut -f1 -d' '`\" = \"#{md5}\"", sudo: true, verifier: has_file?(path)) 69 | end 70 | 71 | # creates a symlink 72 | # @param link [String] link 73 | # @param target [String] target 74 | # @param [Hash] opts options hash 75 | def symlink(link, target, opts = {}) 76 | opts.merge!( 77 | sudo: true, 78 | verifier: has_symlink?(link) 79 | ) 80 | @commands << Command.new("ln -s #{target} #{link}", opts) 81 | end 82 | 83 | # returns a verifier command to test if as a matching content 84 | # @param path [String] path to test 85 | # @param content [String] expected content 86 | def matches_content?(path, content) 87 | md5 = Digest::MD5.hexdigest(content) 88 | Command.new("md5sum #{path} | cut -f1 -d' '`\" = \"#{md5}\"", sudo: true, verifier: has_file?(path)) 89 | end 90 | 91 | # creates a symlink 92 | # @param link [String] link 93 | # @param target [String] target 94 | # @param [Hash] opts options hash 95 | def symlink(link, target, opts = {}) 96 | opts.merge!( 97 | sudo: true, 98 | verifier: has_symlink?(link) 99 | ) 100 | @commands << Command.new("ln -s #{target} #{link}", opts) 101 | end 102 | 103 | # returns a verifier command to test if symlink exists 104 | # @param path [String] path to test 105 | # @param file [String] optionally check if it points to the correct file 106 | def has_symlink?(link, file = nil) 107 | if file 108 | Command.new("'#{file}' = `readlink #{link}`") 109 | else 110 | Command.new("test -L #{link}", sudo: true) 111 | end 112 | end 113 | 114 | # render an ERB template 115 | # @param template [String] name of the template 116 | # @param locals [Hash] hash of variables to pass to the template 117 | def render_template(template, locals) 118 | ERB.new(::File.read("./templates/#{template}")).result_with_hash(locals) 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/prepper/tools/rbenv.rb: -------------------------------------------------------------------------------- 1 | module Prepper 2 | module Tools 3 | # Helper methods for rbenv 4 | module Rbenv 5 | # install rbenv for a given user 6 | # @param user [String] name of the user to install rbenv for 7 | def install_rbenv(user) 8 | apt_install %w{libssl-dev zlib1g zlib1g-dev libreadline-dev} 9 | @commands << Command.new("sudo -u #{user} -i git clone https://github.com/sstephenson/rbenv.git /home/#{user}/.rbenv", verifier: has_directory?("/home/#{user}/.rbenv")) 10 | @commands << Command.new("sudo -u #{user} -i git clone https://github.com/sstephenson/ruby-build.git /home/#{user}/.rbenv/plugins/ruby-build", verifier: has_directory?("/home/#{user}/.rbenv/plugins/ruby-build")) 11 | 12 | append_text 'export PATH="$HOME/.rbenv/bin:$PATH"', "/home/#{user}/.profile" 13 | append_text 'eval "$(rbenv init -)"', "/home/#{user}/.profile" 14 | chown "/home/#{user}/.profile", "#{user}:#{user}" 15 | end 16 | 17 | # install a given ruby version for a given user 18 | # @param user [String] name of the user 19 | # @param version [String] ruby version 20 | def install_ruby(user, version, opts = '') 21 | @commands << Command.new("sudo -u #{user} -i RUBY_CONFIGURE_OPTS='#{opts}' rbenv install #{version}", verifier: has_directory?("/home/#{user}/.rbenv/versions/#{version}")) 22 | 23 | @commands << Command.new("sudo -u #{user} -i rbenv rehash") 24 | @commands << Command.new("sudo -u #{user} -i rbenv global #{version}") 25 | @commands << Command.new("sudo -u #{user} -i rbenv rehash") 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/prepper/tools/text.rb: -------------------------------------------------------------------------------- 1 | module Prepper 2 | module Tools 3 | # text related helpers 4 | module Text 5 | # append text to a file 6 | # @param text [String] text to append 7 | # @param path [String] 8 | def append_text(text, path) 9 | @commands << Command.new("/bin/echo -e '#{text}' | sudo tee -a #{path}", verifier: has_text?(text, path)) 10 | end 11 | 12 | # returns a verifier command to test the presence of a string in a file 13 | # @param text [String] text 14 | # @param path [String] path to file 15 | def has_text?(text, path) 16 | regex = Regexp.escape(text) 17 | Command.new("grep -qPzo '^#{regex}$' #{path} ||", sudo: true) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/prepper/tools/users.rb: -------------------------------------------------------------------------------- 1 | module Prepper 2 | module Tools 3 | # user management related helpers 4 | module Users 5 | # add a user to the host 6 | # @param username [String] name of the user 7 | # @param [Hash] opts options has 8 | # @option opts [String] :flags flags to pass to adduser 9 | def add_user(username, opts = {}) 10 | opts[:flags] << ' --gecos ,,,' 11 | @commands << Command.new("adduser #{username} #{opts[:flags]}", sudo: true, verifier: has_user?(username)) 12 | end 13 | 14 | # returns a verifier command to check if a user exists 15 | # @param username [String] name of the user 16 | # @param [Hash] opts options hash 17 | # @option opts [String] :in_group check if the user is in the given group 18 | def has_user?(username, opts = {}) 19 | if opts[:in_group] 20 | command = "id -nG #{username} | xargs -n1 echo | grep #{opts[:in_group]}" 21 | else 22 | command = "id #{username}" 23 | end 24 | Command.new(command, sudo: true) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/prepper/version.rb: -------------------------------------------------------------------------------- 1 | module Prepper 2 | VERSION = "0.2.1" 3 | end 4 | -------------------------------------------------------------------------------- /lib/sshkit_ext.rb: -------------------------------------------------------------------------------- 1 | require 'sshkit' 2 | SSHKit::Backend::Netssh.class_eval do 3 | # Uploads the given string or file-like object to the current host 4 | # context. Accepts :owner and :mode options that affect the permissions of the 5 | # remote file. 6 | # 7 | def put!(string_or_io, remote_path, opts={}) 8 | sudo_exec = ->(*cmd) { 9 | cmd = [:sudo] + cmd if opts[:sudo] 10 | execute *cmd 11 | } 12 | 13 | tmp_path = "/tmp/#{SecureRandom.uuid}" 14 | 15 | owner = opts[:owner] 16 | mode = opts[:mode] 17 | 18 | source = if string_or_io.respond_to?(:read) 19 | string_or_io 20 | else 21 | StringIO.new(string_or_io.to_s) 22 | end 23 | 24 | sudo_exec.call :mkdir, "-p", File.dirname(remote_path) 25 | 26 | upload!(source, tmp_path) 27 | 28 | sudo_exec.call(:mv, "-f", tmp_path, remote_path) 29 | sudo_exec.call(:chown, owner, remote_path) if owner 30 | sudo_exec.call(:chmod, mode, remote_path) if mode 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /prepper.gemspec: -------------------------------------------------------------------------------- 1 | require_relative 'lib/prepper/version' 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "prepper" 5 | spec.version = Prepper::VERSION 6 | spec.authors = ["Greg Molnar"] 7 | spec.email = ["molnargerg@gmail.com"] 8 | 9 | spec.summary = "Simple server provisioning" 10 | spec.description = "Simple server provisioning " 11 | spec.homepage = "https://github.com/gregmolnar/prepper" 12 | spec.license = "MIT" 13 | spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0") 14 | 15 | # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" 16 | 17 | spec.metadata["homepage_uri"] = spec.homepage 18 | spec.metadata["source_code_uri"] = "https://github.com/gregmolnar/prepper" 19 | # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." 20 | 21 | # Specify which files should be added to the gem when it is released. 22 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 23 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 24 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 25 | end 26 | spec.bindir = "bin" 27 | spec.executables = "prepper" 28 | spec.require_paths = ["lib"] 29 | 30 | spec.add_dependency 'zeitwerk' 31 | spec.add_dependency 'sshkit' 32 | spec.add_dependency 'tty-option' 33 | spec.add_development_dependency 'byebug' 34 | end 35 | -------------------------------------------------------------------------------- /test/prepper/runner_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class RunnerTest < Minitest::Test 4 | def test_it_runs 5 | assert Prepper::Runner.new("") 6 | end 7 | 8 | def test_it_sets_host 9 | code = <<-CODE 10 | server_host "test.com" 11 | CODE 12 | runner = Prepper::Runner.new(code) 13 | assert_equal('test.com', runner.host) 14 | end 15 | 16 | def test_it_sets_user 17 | code = <<-CODE 18 | server_user "ubuntu" 19 | CODE 20 | runner = Prepper::Runner.new(code) 21 | assert_equal('ubuntu', runner.user) 22 | end 23 | 24 | def test_it_sets_port 25 | code = <<-CODE 26 | server_port 999 27 | CODE 28 | runner = Prepper::Runner.new(code) 29 | assert_equal(999, runner.port) 30 | end 31 | 32 | def test_it_sets_ssh_options 33 | code = <<-CODE 34 | ssh_options({ forward_agent: false }) 35 | CODE 36 | runner = Prepper::Runner.new(code) 37 | assert_equal({forward_agent: false}, runner.instance_variable_get("@ssh_options")) 38 | end 39 | 40 | def test_server_hash 41 | code = <<-CODE 42 | server_host "test.com" 43 | server_port 999 44 | server_user "ubuntu" 45 | ssh_options({ forward_agent: false }) 46 | CODE 47 | runner = Prepper::Runner.new(code) 48 | 49 | assert_equal( 50 | { 51 | hostname: 'test.com', 52 | user: 'ubuntu', 53 | port: 999, 54 | ssh_options: {forward_agent: false} 55 | }, 56 | runner.server_hash 57 | ) 58 | end 59 | 60 | def test_add_command_adds_a_package_with_a_command 61 | code = <<-CODE 62 | add_command "ls /" 63 | CODE 64 | runner = Prepper::Runner.new(code) 65 | refute_empty runner.packages 66 | assert_equal 1, runner.packages.size 67 | assert_equal 'base', runner.packages.first.name 68 | package_options = runner.packages.first.instance_variable_get("@opts") 69 | assert_equal 'root', package_options[:user] 70 | assert_equal '/', package_options[:within] 71 | end 72 | 73 | def test_add_command_can_override_user_and_within 74 | code = <<-CODE 75 | add_command "ls", user: "ubuntu", within: "/home/ubuntu" 76 | CODE 77 | runner = Prepper::Runner.new(code) 78 | package_options = runner.packages.first.instance_variable_get("@opts") 79 | assert_equal 'ubuntu', package_options[:user] 80 | assert_equal '/home/ubuntu', package_options[:within] 81 | end 82 | 83 | def test_package_registers_a_package 84 | code = <<-CODE 85 | package "list root" do 86 | add_command "ls /" 87 | end 88 | CODE 89 | runner = Prepper::Runner.new(code) 90 | assert_equal 1, runner.packages.size 91 | assert_equal "list root", runner.packages.first.name 92 | assert_equal 1, runner.packages.first.commands.size 93 | assert_equal runner, runner.packages.first.runner 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/prepper_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PrepperTest < Minitest::Test 4 | def test_that_it_has_a_version_number 5 | refute_nil ::Prepper::VERSION 6 | end 7 | 8 | def test_it_does_something_useful 9 | assert true 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 2 | require "prepper" 3 | 4 | require "minitest/autorun" 5 | --------------------------------------------------------------------------------