├── Gemfile ├── .rspec ├── lib ├── bootscript │ ├── version.rb │ ├── uu_writer.rb │ ├── chef.rb │ └── script.rb ├── templates │ ├── windows_footer.ps1.erb │ ├── chef │ │ ├── attributes.json.erb │ │ ├── ramdisk_secrets.rb.erb │ │ ├── json_attributes.rb.erb │ │ ├── ssl_config.rb.erb │ │ ├── chef_client.conf.erb │ │ ├── chef-install.sh.erb │ │ └── chef-install.ps1.erb │ ├── windows_header.ps1.erb │ ├── bootscript.ps1.erb │ └── bootscript.sh.erb └── bootscript.rb ├── spec ├── spec_helper.rb ├── bootscript │ ├── uu_writer_spec.rb │ ├── chef_spec.rb │ └── script_spec.rb ├── unpacker.rb └── bootscript_spec.rb ├── Rakefile ├── .gitignore ├── LICENSE.txt ├── bootscript.gemspec ├── ERB_VARS.md └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color --format documentation -r ./spec/spec_helper.rb 2 | -------------------------------------------------------------------------------- /lib/bootscript/version.rb: -------------------------------------------------------------------------------- 1 | module Bootscript 2 | VERSION = "0.2.1" 3 | end 4 | -------------------------------------------------------------------------------- /lib/templates/windows_footer.ps1.erb: -------------------------------------------------------------------------------- 1 | main 2 | }.Invoke($args) 3 | 4 | Stop-Transcript 5 | <% if add_script_tags; %><% end %> 6 | -------------------------------------------------------------------------------- /lib/templates/chef/attributes.json.erb: -------------------------------------------------------------------------------- 1 | <%= 2 | if defined? chef_attributes 3 | JSON.pretty_generate(chef_attributes) 4 | else 5 | '{}' 6 | end 7 | %> 8 | -------------------------------------------------------------------------------- /lib/templates/chef/ramdisk_secrets.rb.erb: -------------------------------------------------------------------------------- 1 | validation_key "<%= ramdisk_mount %>/validation.pem" 2 | # data bag decryption secret is at <%= ramdisk_mount %>/encrypted_data_bag_secret 3 | -------------------------------------------------------------------------------- /lib/templates/chef/json_attributes.rb.erb: -------------------------------------------------------------------------------- 1 | attributes_file = "#{::File.dirname(__FILE__)}/../attributes.json" 2 | if ::File.exists? attributes_file 3 | json_attribs attributes_file 4 | end 5 | -------------------------------------------------------------------------------- /lib/templates/chef/ssl_config.rb.erb: -------------------------------------------------------------------------------- 1 | <% if platform =~ /windows/i %> 2 | ENV['SSL_CERT_FILE'] = 'C:\opscode\chef\embedded\ssl\certs\cacert.pem' 3 | <% end %> 4 | 5 | ssl_verify_mode :verify_peer 6 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler.setup 3 | require "#{File.dirname __FILE__}/unpacker" 4 | require 'rspec' 5 | RSpec.configure do |c| 6 | c.mock_with :rspec 7 | end 8 | require 'bootscript' 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | desc 'Default: Run specs' 5 | task :default => :spec 6 | 7 | desc "Run specs" 8 | RSpec::Core::RakeTask.new # options read from .rspec 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .idea 6 | .yardoc 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | 20 | # editors 21 | [#]*[#] 22 | .\#* 23 | -------------------------------------------------------------------------------- /lib/bootscript/uu_writer.rb: -------------------------------------------------------------------------------- 1 | module Bootscript 2 | 3 | class UUWriter 4 | 5 | attr_reader :bytes_written 6 | 7 | def initialize(output) 8 | @output = output 9 | @bytes_written = 0 10 | end 11 | 12 | def write(data) 13 | @bytes_written += @output.write [data].pack('m') 14 | end 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /lib/templates/windows_header.ps1.erb: -------------------------------------------------------------------------------- 1 | <% if add_script_tags; %><% end %> 2 | if ((Get-ExecutionPolicy) -ne "Unrestricted") 3 | { 4 | Set-ExecutionPolicy -ExecutionPolicy Unrestricted -Force -Scope Process 5 | } 6 | 7 | $ErrorActionPreference="SilentlyContinue" 8 | Stop-Transcript | Out-Null 9 | $ErrorActionPreference = "Continue" 10 | 11 | Start-Transcript -Path "c:\bootscript.log" -Append 12 | 13 | { 14 | -------------------------------------------------------------------------------- /lib/templates/chef/chef_client.conf.erb: -------------------------------------------------------------------------------- 1 | <% %w{node_name chef_server_url validation_client_name environment}.each do |param| %> 2 | <% if chef_attributes['chef_client']['config'][param] %> 3 | <%= param %> '<%= chef_attributes['chef_client']['config'][param] %>' 4 | <% end %> 5 | <% end %> 6 | chef_dir = "<%= platform =~ /windows/i ? '/chef' : '/etc/chef' %>" 7 | Dir.glob(File.join(chef_dir, "client.d", "*.rb")).each do |conf| 8 | Chef::Config.from_file(conf) 9 | end 10 | -------------------------------------------------------------------------------- /spec/bootscript/uu_writer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'bootscript' 2 | include Bootscript # for brevity 3 | 4 | describe UUWriter do 5 | 6 | #### TEST PUBLIC INSTANCE MEMBER VARIABLES 7 | 8 | it "exposes a the number of bytes written as an integer" do 9 | UUWriter.new(nil).should respond_to(:bytes_written) 10 | UUWriter.new(nil).bytes_written.should be_a Fixnum 11 | end 12 | 13 | #### TEST PUBLIC METHODS 14 | 15 | describe :initialize do 16 | it "sets bytes_written to zero" do 17 | UUWriter.new(nil).bytes_written.should == 0 18 | end 19 | end 20 | 21 | describe :write do 22 | it "writes the uuencoded version of its argument to its output member" do 23 | destination = StringIO.open("", 'w') 24 | UUWriter.new(destination).write("Encode me!") 25 | destination.close 26 | destination.string.should == ["Encode me!"].pack('m') 27 | end 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Medidata Solutions, Inc. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /spec/unpacker.rb: -------------------------------------------------------------------------------- 1 | module Bootscript 2 | require 'open3' 3 | 4 | # a utility class for testing BootScripts 5 | # only suitable for BootScripts that fit in memory! 6 | class Unpacker 7 | 8 | attr_reader :config, :text, :command 9 | 10 | @text # the actual text of the rendered BootScript 11 | 12 | def initialize(script_text) 13 | @text = script_text 14 | @config = Hash.new 15 | parse 16 | end 17 | 18 | def parse 19 | # try to grab Bash config statements from the text of the script 20 | @text.each_line do |line| 21 | break if line =~ /\A__ARCHIVE_FOLLOWS__/ 22 | if matches = line.match(%r{(\w+)=(.*)}) 23 | key, value = matches[1], matches[2] 24 | value.gsub!(/\A['"]+|['"]+\Z/, "") # strip quotes 25 | @config[key] = value 26 | end 27 | end 28 | end 29 | 30 | # extracts the contents of the BootScript's archive into dir 31 | def unpack_to(dir) 32 | Open3.popen3('uudecode -o /dev/stdout | tar xz', chdir: dir) do 33 | |stdin, stdout, stderr, thread| 34 | stdin.write @text 35 | stderr.read # (why is this is needed for successfull unpacking?) 36 | end 37 | end 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /bootscript.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'bootscript/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "bootscript" 8 | spec.version = Bootscript::VERSION 9 | spec.authors = ["Benton Roberts"] 10 | spec.email = ["broberts@mdsol.com"] 11 | spec.description = %q{Constructs a self-extracting archive, wrapped in a script, for securely initializing cloud systems} 12 | spec.summary = %q{Constructs a self-extracting archive, wrapped in a script, for securely initializing cloud systems} 13 | spec.homepage = "http://github.com/mdsol/bootscript" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency "erubis" 22 | spec.add_dependency "minitar" 23 | spec.add_dependency "rubyzip" 24 | spec.add_dependency "json" 25 | 26 | spec.add_development_dependency "bundler", "~> 1.3" 27 | spec.add_development_dependency "rake" 28 | spec.add_development_dependency "rspec" 29 | spec.add_development_dependency "ZenTest" 30 | spec.add_development_dependency "yard" 31 | end 32 | -------------------------------------------------------------------------------- /spec/bootscript/chef_spec.rb: -------------------------------------------------------------------------------- 1 | require 'bootscript' 2 | include Bootscript 3 | 4 | describe Chef do 5 | 6 | describe :files do 7 | context "given a set of ERB template vars" do 8 | erb_vars = { 9 | ramdisk_mount: '/mount/myramdisk', 10 | chef_validation_pem: 'MYPEM', 11 | chef_databag_secret: 'SECRET', 12 | } 13 | it "returns a Hash mapping locations on the boot target to local data" do 14 | Chef.files(erb_vars).should be_a Hash 15 | end 16 | it "maps the Chef Validation data into place on the target's RAMdisk" do 17 | Chef.files(erb_vars)[ 18 | "#{erb_vars[:ramdisk_mount]}/chef/validation.pem" 19 | ].should be erb_vars[:chef_validation_pem] 20 | end 21 | it "maps the Chef data bag secret into place on the target's RAMdisk" do 22 | Chef.files(erb_vars)[ 23 | "#{erb_vars[:ramdisk_mount]}/chef/encrypted_data_bag_secret" 24 | ].should be erb_vars[:chef_databag_secret] 25 | end 26 | end 27 | end 28 | 29 | describe :included? do 30 | desired_key = :chef_validation_pem 31 | context "given a set of ERB template vars with key :#{desired_key}" do 32 | it "returns true" do 33 | Chef.included?(chef_validation_pem: 'SOME DATA').should be true 34 | end 35 | end 36 | context "given a set of ERB template vars without key :#{desired_key}" do 37 | it "returns false" do 38 | Chef.included?({}).should be false 39 | end 40 | end 41 | context "given nothing" do 42 | it "returns false" do 43 | Chef.included?().should be false 44 | end 45 | end 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /ERB_VARS.md: -------------------------------------------------------------------------------- 1 | List of Template Variables 2 | ================ 3 | Here is a list of all the keys that a call to `Bootscript.generate` will check for, and how they affect the gem's behavior. 4 | 5 | 6 | Main settings 7 | ---------------- 8 | * `:platform` - when set to `:unix` (default), the gem produces a Bash script as output. When set to `:windows`, the gem produces Powershell wrapped in a Batch script. 9 | * `:startup_command` - The command to be executed after the archive is extracted. If Chef support is enabled (by setting `chef_validation_pem`), this value defaults to a platform-specific command that invokes the built-in Chef support. Otherwise, it defaults to `nil`, and no startup command is executed. In either case, it can be overridden. 10 | * `:update_os` - If `true`, will attempt to upgrade all the operating system packages. Defaults to `false`. _(Debian/Ubuntu only for now.)_ 11 | * `:add_script_tags` - _(Windows only.)_ When set to `true`, encloses the output in `` XML tags, usually required for AWS. 12 | 13 | 14 | Chef settings 15 | ---------------- 16 | The Chef-based examples in the README illustrate the included Chef support. 17 | * `:chef_validation_pem` - When set to any non-nil value, enables the built-in Chef support. The value must be the key data itself, so read it into memory first. 18 | * `:chef_databag_secret` - The secret used to decrypt the Chef org's encrypted data bag items. 19 | * `:chef_attributes` - a Hash of Chef attributes that is read as `node[chef_client][config]` by the [Opscode chef-client cookbook][1]. This is where you specify your `node_name` (if desired), `chef_server_url`, `validation_client_name`, etc. *Always use strings for chef attribute keys, not symbols!* 20 | 21 | 22 | RAMdisk settings 23 | ---------------- 24 | * `:create_ramdisk` - Setting this to `true` generates a bootscript that creates a RAMdisk of a configurable size and at a configurable filesystem location. This happens even before the archive is unpacked, so you can extract files into the RAMdisk. 25 | * `:ramdisk_mount` - The filesystem location where the RAMdisk is mounted. (defaults to `false`) 26 | * `:ramdisk_size` - Size, in Megabytes, of the RAMdisk. (defaults to 20) 27 | 28 | 29 | -------- 30 | [1]:https://github.com/opscode-cookbooks/chef-client 31 | -------------------------------------------------------------------------------- /lib/templates/bootscript.ps1.erb: -------------------------------------------------------------------------------- 1 | $startup_cmd = '<%= startup_command %>' 2 | $createRAMDisk = $<%= create_ramdisk.to_s.upcase %> 3 | $RAMDiskSize = "<%= ramdisk_size %>M" 4 | $RAMDiskMount = "<%= ramdisk_mount %>" 5 | $IMDiskURL = "<%= imdisk_url %>" 6 | $IMDisk = "C:\windows\system32\imdisk.exe" 7 | $IMDiskInstaller = "C:\imdiskinst.exe" 8 | 9 | function main() 10 | { 11 | echo "Starting bootstrap..." 12 | Decrypt-Archive 13 | if ($createRAMDisk) 14 | { 15 | Download-ImDisk 16 | Install-ImDisk 17 | Execute-ImDisk 18 | echo "RAMDisk setup complete." 19 | } 20 | Execute-Command($startup_cmd) 21 | echo "Bootstrap complete." 22 | } 23 | 24 | function Download-ImDisk() 25 | { 26 | if ((test-path $IMDiskInstaller) -ne $true) 27 | { 28 | echo "Downloading ImDisk utility..." 29 | $wc = new-object System.Net.WebClient 30 | try { $wc.DownloadFile($IMDiskURL, $IMDiskInstaller) } 31 | catch { throw $error[0] } 32 | } 33 | } 34 | 35 | function Install-ImDisk() 36 | { 37 | if ((test-path $IMDisk) -ne $true) 38 | { 39 | $Env:IMDISK_SILENT_SETUP = 1 40 | Execute-Command("$IMDiskInstaller -y") 41 | } 42 | } 43 | 44 | function Execute-ImDisk() 45 | { 46 | if ((test-path $RAMDiskMount) -ne $true) 47 | { 48 | echo "Setting up $RAMDiskSize RAMDisk at $RAMDiskMount..." 49 | $fsArgs = '"/fs:ntfs /q /y"' 50 | Execute-Command("$IMDisk -a -t vm -m $RAMDiskMount -s $RAMDiskSize -p $fsArgs") 51 | } 52 | } 53 | 54 | function Decrypt-Archive() 55 | { 56 | $archive_path = "C:\bootscript_archive.zip" 57 | [io.file]::WriteAllBytes($archive_path, 58 | [System.Convert]::FromBase64String($archive)) 59 | Expand-ZIPFile $archive_path "C:\" 60 | Remove-Item $archive_path 61 | } 62 | 63 | function Expand-ZIPFile($file, $dest) 64 | { 65 | $shell = new-object -com shell.application 66 | $zip = $shell.NameSpace($file) 67 | foreach($item in $zip.items()) 68 | { $shell.Namespace($dest).copyhere($item) } 69 | } 70 | 71 | function Execute-Command($cmd) 72 | { 73 | if ($cmd -ne "") 74 | { 75 | Try { 76 | echo "Running: $cmd" 77 | $Env:_THIS_CMD = $cmd 78 | $proc = Start-Process -FilePath c:\windows\system32\cmd.exe ` 79 | -ArgumentList "/C", "%_THIS_CMD%" ` 80 | -Verbose -Debug -Wait -Passthru 81 | do { start-sleep -Milliseconds 500 } 82 | until ($proc.HasExited) 83 | echo "Finished: $cmd" 84 | } 85 | Catch { 86 | echo "Failed: $cmd" 87 | echo "Error was: $error[0]" 88 | throw $error[0] 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /spec/bootscript_spec.rb: -------------------------------------------------------------------------------- 1 | require 'bootscript' 2 | 3 | describe Bootscript do 4 | describe :DEFAULT_VARS do 5 | it "provides some sane default template variables" do 6 | [ :platform, :startup_command, 7 | :create_ramdisk, :ramdisk_size, :ramdisk_mount, 8 | :add_script_tags, :inst_pkgs 9 | ].each do |required_var| 10 | Bootscript::DEFAULT_VARS.should include(required_var) 11 | Bootscript::DEFAULT_VARS[required_var].should_not be nil 12 | end 13 | end 14 | end 15 | 16 | describe :generate do 17 | before :each do 18 | @template_vars = {key1: :value1, key2: :value2} 19 | @data_map = {'/use/local/bin/hello.sh' => "echo Hello!\n"} 20 | @script = Bootscript::Script.new 21 | end 22 | it "accepts a Hash of template vars and a data map" do 23 | Bootscript.generate(@template_vars, @data_map) 24 | end 25 | it "creates a Script with the same data map" do 26 | Bootscript::Script.stub(:new).and_return @script 27 | @script.should_receive(:data_map=).with @data_map 28 | Bootscript.generate(@template_vars, @data_map) 29 | end 30 | it "calls generate() on the script, passing the same template vars" do 31 | Bootscript::Script.stub(:new).and_return @script 32 | @script.should_receive(:generate).with(@template_vars, nil) 33 | Bootscript.generate(@template_vars, @data_map) 34 | end 35 | end 36 | 37 | # determines whether Windows is the boot target for a given set of ERB vars 38 | describe :windows? do 39 | [:windows, :WinDoWs, 'windows', 'WINDOWS'].each do |value| 40 | context "its Hash argument has :platform => #{value} (#{value.class})" do 41 | it "returns true" do 42 | Bootscript.windows?(platform: value).should be true 43 | end 44 | end 45 | end 46 | [:unix, :OS_X, 'other', 'randomstring0940358'].each do |value| 47 | context "its Hash argument has :platform => #{value} (#{value.class})" do 48 | it "returns false" do 49 | Bootscript.windows?(platform: value).should be false 50 | end 51 | end 52 | end 53 | end 54 | 55 | describe :default_logger do 56 | context "with no arguments" do 57 | it "returns a Ruby Logger with LOG_LEVEL set to FATAL" do 58 | logger = Bootscript.default_logger 59 | logger.level.should be Logger::FATAL 60 | end 61 | end 62 | context "with a specific log level" do 63 | [Logger::DEBUG, Logger::INFO, Logger::WARN].each do |log_level| 64 | it "returns a standard Ruby Logger with level #{log_level}" do 65 | logger = Bootscript.default_logger(STDOUT, log_level) 66 | logger.level.should be log_level 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/bootscript/chef.rb: -------------------------------------------------------------------------------- 1 | module Bootscript 2 | # provides built-in Chef templates and attributes 3 | module Chef 4 | 5 | # returns a map of the built-in Chef templates, in the context of erb_vars 6 | # The presence of :chef_validation_pem triggers the inclusion of Chef 7 | def self.files(erb_vars) 8 | if Bootscript.windows?(erb_vars) 9 | files_for_windows(erb_vars) 10 | else 11 | files_for_unix(erb_vars) 12 | end 13 | end 14 | 15 | # defines whether or not Chef support will be included in the boot script, 16 | # based on the presence of a certain key or keys in erb_vars 17 | # @param [Hash] erb_vars template vars to use for determining Chef inclusion 18 | # @return [Boolean] true if erb_vars has the key :chef_validation_pem 19 | def self.included?(erb_vars = {}) 20 | erb_vars.has_key? :chef_validation_pem 21 | end 22 | 23 | private 24 | 25 | def self.files_for_unix(erb_vars) 26 | template_dir = "#{Bootscript::BUILTIN_TEMPLATE_DIR}/chef" 27 | { # built-in files 28 | '/usr/local/sbin/chef-install.sh' => 29 | File.new("#{template_dir}/chef-install.sh.erb"), 30 | '/etc/chef/attributes.json' => 31 | File.new("#{template_dir}/attributes.json.erb"), 32 | '/etc/chef/client.d/include_json_attributes.rb' => 33 | File.new("#{template_dir}/json_attributes.rb.erb"), 34 | '/etc/chef/client.d/ssl_config.rb' => 35 | File.new("#{template_dir}/ssl_config.rb.erb"), 36 | '/etc/chef/client.rb' => 37 | File.new("#{template_dir}/chef_client.conf.erb"), 38 | # files generated from required ERB vars 39 | "#{erb_vars[:ramdisk_mount]}/chef/validation.pem" => 40 | erb_vars[:chef_validation_pem] || '', 41 | "#{erb_vars[:ramdisk_mount]}/chef/encrypted_data_bag_secret" => 42 | erb_vars[:chef_databag_secret] || '', 43 | } 44 | end 45 | 46 | def self.files_for_windows(erb_vars) 47 | template_dir = "#{Bootscript::BUILTIN_TEMPLATE_DIR}/chef" 48 | files = { # built-in files 49 | 'chef/chef-install.ps1' => 50 | File.new("#{template_dir}/chef-install.ps1.erb"), 51 | 'chef/client.rb' => 52 | File.new("#{template_dir}/chef_client.conf.erb"), 53 | 'chef/attributes.json' => 54 | File.new("#{template_dir}/attributes.json.erb"), 55 | 'chef/client.d/include_json_attributes.rb' => 56 | File.new("#{template_dir}/json_attributes.rb.erb"), 57 | 'chef/client.d/ssl_config.rb' => 58 | File.new("#{template_dir}/ssl_config.rb.erb"), 59 | # files generated from required ERB vars 60 | "chef/validation.pem" => 61 | erb_vars[:chef_validation_pem] || '', 62 | "chef/encrypted_data_bag_secret" => 63 | erb_vars[:chef_databag_secret] || '', 64 | } 65 | if erb_vars[:create_ramdisk] 66 | files.merge!( 67 | 'chef/client.d/ramdisk_secrets.rb' => 68 | File.new("#{template_dir}/ramdisk_secrets.rb.erb")) 69 | end 70 | files 71 | end 72 | 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/templates/chef/chef-install.sh.erb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Installs chef-client via the ombnibus installer (with minimal dependencies), 3 | # and tries to converge twice: first, with just this runlist - 4 | # recipe[chef-client::config],recipe[chef-client::service] 5 | # ; and then again with the full runlist. 6 | set -e 7 | test $UID == 0 || (echo "Error: must run as root"; exit 1) 8 | echo "Installing Chef with ${0}..." 9 | 10 | ######### STEP 0: CONFIG, rendered by bootscript gem 11 | NODE_NAME="<%= chef_attributes['chef_client']['config']['node_name'] %>" 12 | CHEF_URL="<%= chef_attributes['chef_client']['config']['chef_server_url'] %>" 13 | VALIDATION_PEM='<%= ramdisk_mount %>/chef/validation.pem' 14 | OHAI_HINTS_DIR='/etc/chef/ohai/hints' 15 | DATABAG_SECRET='<%= ramdisk_mount %>/chef/encrypted_data_bag_secret' 16 | CHEF_VERSION="<%= (defined? chef_version) ? chef_version : '' %>" 17 | CHEF_INITIAL_RUNLIST="<%= 18 | (defined? chef_initial_runlist) ? chef_initial_runlist : '' %>" 19 | OMNIBUS_INSTALLER_URL="https://omnitruck.chef.io/install.sh" 20 | CHEF_BIN="/usr/bin/chef-client" # Unix Chef omnibus always symlinks here 21 | PID_FILE="/var/run/chef/client.pid" 22 | 23 | ######### STEP 1: MOVE CHEF SECRETS INTO PLACE 24 | chmod 0600 "$VALIDATION_PEM" 25 | if ! [ "$VALIDATION_PEM" == "/etc/chef/validation.pem" ] ; then 26 | ln -sf "$VALIDATION_PEM" "/etc/chef/validation.pem" 27 | fi 28 | if [ -e "$DATABAG_SECRET" ] ; then 29 | chmod 0600 "$DATABAG_SECRET" 30 | if ! [ "$DATABAG_SECRET" == "/etc/chef/encrypted_data_bag_secret" ] ; then 31 | ln -sf "$DATABAG_SECRET" "/etc/chef/encrypted_data_bag_secret" 32 | fi 33 | fi 34 | 35 | ######### STEP 2: CONFIGURE OPERATING SYSTEM AND INSTALL DEPENDENCIES 36 | echo "Performing package update..." 37 | if [ "$DistroBasedOn" == 'debian' ] ; then 38 | export DEBIAN_FRONTEND=noninteractive 39 | apt-get update -y 40 | echo "Installing build-essential and curl..." 41 | apt-get --force-yes -y install build-essential curl 42 | else 43 | echo "Skipping..." 44 | fi 45 | 46 | ######### STEP 3: INSTALL CHEF VIA OMNIBUS INSTALLER 47 | echo "Downloading Chef..." 48 | installer=$(basename $OMNIBUS_INSTALLER_URL) 49 | if ! (which $CHEF_BIN >/dev/null 2>&1) ; then 50 | if (which curl >/dev/null 2>&1); then 51 | curl -L -o $installer $OMNIBUS_INSTALLER_URL 52 | else 53 | echo "Cannot find curl - cannot download Chef!" 54 | fi 55 | fi 56 | cmd="bash $installer" 57 | if ! [ "$CHEF_VERSION" == "" ] ; then 58 | cmd="$cmd -v ${CHEF_VERSION}" 59 | fi 60 | echo "Installing Chef..." 61 | $cmd 62 | rm -f $installer 63 | 64 | ######### STEP 4: SET UP ENVIRONMENT HINTS FOR OHAI 65 | echo "Establishing ohai hints directory: $OHAI_HINTS_DIR" 66 | mkdir -p "$OHAI_HINTS_DIR" 67 | if curl -m 60 -f -L -s -S -o /dev/null http://169.254.169.254; then 68 | echo "Setting ohai hint: ec2" 69 | touch "$OHAI_HINTS_DIR/ec2.json" 70 | fi 71 | 72 | ######### STEP 5: INITIAL, ONE-PASS CHEF CONVERGENCE 73 | echo "Performing initial convergence..." 74 | cmd="$CHEF_BIN --once --no-color" 75 | if [[ "$CHEF_INITIAL_RUNLIST" != "" ]] ; then 76 | cmd="$cmd -o $CHEF_INITIAL_RUNLIST" 77 | fi 78 | echo $cmd 79 | $cmd 80 | 81 | ######### STEP 6: SIGNAL SERVICE TO CONVERGE 82 | echo "Signalling chef service to converge..." 83 | killall -USR1 chef-client || echo " (...no chef client service detected)" 84 | 85 | echo "Done." 86 | -------------------------------------------------------------------------------- /lib/bootscript.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'bootscript/version' 3 | require 'bootscript/script' 4 | require 'bootscript/uu_writer' 5 | require 'bootscript/chef' 6 | 7 | # provides the software's only public method, generate() 8 | module Bootscript 9 | 10 | # These values are interpolated into all templates, and can be overridden 11 | # in calls to {Bootscript#generate} 12 | DEFAULT_VARS = { 13 | platform: :unix, # or :windows 14 | create_ramdisk: false, 15 | startup_command: '', # customized by platform if chef used 16 | ramdisk_mount: '', # customized by platform, see platform_defaults 17 | ramdisk_size: 20, # Megabytes 18 | add_script_tags: false, 19 | script_name: 'bootscript', # base name of the boot script 20 | strip_comments: true, 21 | imdisk_url: 'http://www.ltr-data.se/files/imdiskinst.exe', 22 | update_os: false, 23 | inst_pkgs: 'bash openssl openssh-server' #list of packages you want upgraded 24 | } 25 | 26 | # Generates the full text of a boot script based on the supplied 27 | # template_vars and data_map. If no optional destination is supplied, 28 | # the full text is returned as a String. Otherwise, the text is 29 | # written to the destination using write(), and the number of bytes 30 | # written is returned. 31 | def self.generate(template_vars = {}, data_map = {}, destination = nil) 32 | script = Bootscript::Script.new(template_vars[:logger]) 33 | script.data_map = data_map 34 | script.generate(template_vars, destination) 35 | end 36 | 37 | # Returns true if the passed Hash of erb_vars indicate a 38 | # Windows boot target 39 | def self.windows?(erb_vars) 40 | (erb_vars[:platform] || '').to_s.downcase == 'windows' 41 | end 42 | 43 | # Returns a slightly-modified version of the default Ruby Logger 44 | # @param output [STDOUT, File, etc.] where to write the logs 45 | # @param level [DEBUG|INFO|etc.] desired minimum severity 46 | # @return [Logger] a standard Ruby Logger with a nicer output format 47 | def self.default_logger(output = nil, level = Logger::FATAL) 48 | logger = ::Logger.new(output || STDOUT) 49 | logger.sev_threshold = level 50 | logger.formatter = proc {|lvl, time, prog, msg| 51 | "#{lvl} #{time.strftime '%Y-%m-%d %H:%M:%S %Z'}: #{msg}\n" 52 | } 53 | logger 54 | end 55 | 56 | # Returns the passed Hash of template vars, merged over a set of 57 | # computed, platform-specific default variables 58 | def self.merge_platform_defaults(vars) 59 | defaults = DEFAULT_VARS.merge(vars) 60 | if defaults[:platform].to_s == 'windows' 61 | defaults[:ramdisk_mount] = 'R:' 62 | defaults[:script_name] = 'bootscript.ps1' 63 | if Chef::included?(defaults) 64 | defaults[:startup_command] = 'PowerShell -Command "& '+ 65 | '{C:/chef/chef-install.ps1}" > c:/chef/bootscript_setup.log 2>&1' 66 | end 67 | else 68 | defaults[:ramdisk_mount] = '/etc/secrets' 69 | defaults[:script_name] = 'bootscript.sh' 70 | if Chef::included?(defaults) 71 | defaults[:startup_command] = '/usr/local/sbin/chef-install.sh' 72 | end 73 | end 74 | defaults.merge(vars) # return user vars merged over platform defaults 75 | end 76 | 77 | BUILTIN_TEMPLATE_DIR = File.dirname(__FILE__)+"/templates" 78 | UNIX_TEMPLATE = "#{BUILTIN_TEMPLATE_DIR}/bootscript.sh.erb" 79 | WINDOWS_TEMPLATE = "#{BUILTIN_TEMPLATE_DIR}/bootscript.ps1.erb" 80 | 81 | end 82 | -------------------------------------------------------------------------------- /lib/templates/chef/chef-install.ps1.erb: -------------------------------------------------------------------------------- 1 | echo "Starting Chef installation..." 2 | 3 | $ChefVers = "<%= (defined? chef_version) ? chef_version : '' %>" 4 | $ChefPath = "C:\Chef" 5 | $OhaiHintsDir = "$ChefPath\ohai\hints" 6 | $ChefLog = "$ChefPath\log\bootscript_log.txt" 7 | $CreateRAMDisk = $<%= create_ramdisk.to_s.upcase %> 8 | $RAMDiskMount = "<%= ramdisk_mount %>" 9 | 10 | function main() 11 | { 12 | try 13 | { 14 | Create-Chef-Directory-Structure 15 | Create-Ohai-Hints 16 | if ($createRAMDisk) { Move-Chef-Secrets-To-Ramdisk } 17 | Install-Chef-Client 18 | Execute-Chef-Client 19 | } 20 | catch{ 21 | write-error $error[0] 22 | exit 1 23 | } 24 | } 25 | 26 | function Create-EC2-Hint() 27 | { 28 | try { 29 | $wc = New-Object System.Net.WebClient 30 | $wc.DownloadString("http://169.254.169.254") | Out-Null 31 | New-Item -Type file -Force -Path (Join-Path $OhaiHintsDir "ec2.json") | Out-Null 32 | } catch { 33 | # We are not an EC2 instance. 34 | } 35 | } 36 | 37 | function Create-Ohai-Hints() 38 | { 39 | try 40 | { 41 | Create-EC2-Hint 42 | } 43 | catch {throw $error[0]} 44 | } 45 | 46 | function Create-Chef-Directory-Structure() 47 | { 48 | try 49 | { 50 | @("etc", "bin", "log", "tmp", "var") | foreach { 51 | New-Item -force -type directory -Path (Join-Path $ChefPath $_) | out-null 52 | } 53 | } 54 | catch {throw $error[0]} 55 | } 56 | 57 | function Move-Chef-Secrets-To-Ramdisk() 58 | { 59 | echo "Moving Chef secrets to $RAMDiskMount" 60 | Move-Item (Join-Path $ChefPath "validation.pem") $RAMDiskMount 61 | Move-Item (Join-Path $ChefPath "encrypted_data_bag_secret") $RAMDiskMount 62 | } 63 | 64 | function Install-Chef-Client() 65 | { 66 | echo "Downloading Chef installer..." 67 | $sourceFile = (Join-Path $ChefPath "chef-client.msi") 68 | $wc = new-object System.Net.WebClient 69 | try 70 | { 71 | $installerURL = Chef-URL 72 | echo "Downloading $installerURL -> $sourceFile" 73 | $wc.DownloadFile($installerURL, $sourceFile) 74 | if ((test-path $sourceFile) -ne $true){ throw "File not found: $sourceFile" } 75 | echo "Installing Chef installer ($sourceFile) with msiexec..." 76 | Execute-Command("msiexec /qn /i $sourceFile") 77 | } 78 | catch{ 79 | throw $error[0] 80 | } 81 | } 82 | 83 | function Chef-Url() 84 | { 85 | $major_rev = [System.Environment]::OSVersion.Version.Major 86 | $minor_rev = [System.Environment]::OSVersion.Version.Minor 87 | $winrev = "$major_rev.$minor_rev" 88 | $arch = "x86_64" 89 | if ($winrev -eq "5.1") { $machineos = "2003" } 90 | elseif ($winrev -eq "6.0") { $machineos = "2008" } 91 | elseif ($winrev -eq "6.1") { $machineos = "2008r2" } 92 | elseif ($winrev -eq "6.2") { $machineos = "2012" } 93 | elseif ($winrev -eq "6.3") { $machineos = "2012r2" } 94 | elseif ($winrev -eq "10.0") {$machineos = "2016" } 95 | else { throw "ERROR: Windows Server 2003, 2008, 2012 or 2016 required" } 96 | $url = "https://omnitruck.chef.io/stable/chef/download?p=windows&pv=$machineos&m=$arch" 97 | if ($ChefVers -ne "") { $url = "$url&v=$ChefVers" } 98 | return $url 99 | } 100 | 101 | function Execute-Chef-Client() 102 | { 103 | try 104 | { 105 | $Env:Path = "$Env:Path;C:\opscode\chef\bin;C:\opscode\chef\embedded\bin" 106 | $Env:SSL_CERT_FILE = "C:\opscode\chef\embedded\ssl\certs\cacert.pem" 107 | 108 | echo "Performing initial convergence..." 109 | $tinyRunlist = "recipe[chef-client::config],recipe[chef-client::service]" 110 | $logOptions = "-l info -L $ChefLog" 111 | Execute-Command("chef-client --once --no-color $logOptions -o $tinyRunlist") 112 | 113 | echo "Performing full convergence..." 114 | Execute-Command("chef-client --once --no-color $logOptions") 115 | 116 | echo "Initial Chef runs completed - see $ChefLog" 117 | } 118 | catch 119 | { 120 | echo "Chef client execution failed" 121 | echo "Error was: $error[0]" 122 | throw $error[0] 123 | } 124 | } 125 | 126 | function Execute-Command($cmd) 127 | { 128 | if ($cmd -ne "") 129 | { 130 | Try { 131 | echo "Running: $cmd" 132 | $Env:_THIS_CMD = $cmd 133 | $proc = Start-Process -FilePath c:\windows\system32\cmd.exe ` 134 | -ArgumentList "/C", "%_THIS_CMD%" ` 135 | -Verbose -Debug -Wait -Passthru 136 | do { start-sleep -Milliseconds 500 } 137 | until ($proc.HasExited) 138 | echo "Finished: $cmd" 139 | } 140 | Catch { 141 | echo "Failed: $cmd" 142 | echo "Error was: $error[0]" 143 | throw $error[0] 144 | } 145 | } 146 | } 147 | 148 | main 149 | -------------------------------------------------------------------------------- /spec/bootscript/script_spec.rb: -------------------------------------------------------------------------------- 1 | require 'bootscript' 2 | require 'logger' # for testing logging functionality 3 | require 'tmpdir' # for unpacking Script archives 4 | include Bootscript # for brevity 5 | 6 | describe Script do 7 | 8 | #### TEST SETUP 9 | before :each do 10 | @script = Script.new() 11 | end 12 | 13 | #### TEST PUBLIC INSTANCE MEMBER VARIABLES 14 | 15 | it "has a public @data_map Hash, for mapping local data to the boot target" do 16 | Script.new().should respond_to(:data_map) 17 | Script.new().data_map.should be_a Hash 18 | end 19 | 20 | it "exposes a Ruby Logger as its public @log member, to adjust log level" do 21 | Script.new().should respond_to(:log) 22 | Script.new().log.should be_a Logger 23 | end 24 | 25 | #### TEST PUBLIC METHODS 26 | 27 | describe :initialize do 28 | context "when invoked with a logger" do 29 | it "sets the BootScript's @log to the passed Logger object" do 30 | my_logger = Logger.new(STDOUT) 31 | Script.new(my_logger).log.should be my_logger 32 | end 33 | end 34 | context "when invoked with no logger" do 35 | it "assigns a default Logger the BootScript's @log" do 36 | Script.new().log.should be_a Logger 37 | end 38 | end 39 | end 40 | 41 | describe :generate do 42 | # test arguments / arity 43 | it "accepts an (optional) Hash of template vars" do 44 | expect{@script.generate}.to_not raise_error 45 | expect{@script.generate({some_key: :some_value})}.to_not raise_error 46 | end 47 | it "accepts an (optional) destination for the generated text" do 48 | File.open('/dev/null', 'w') do |outfile| # write to nowhere! 49 | expect{@script.generate({}, outfile)}.to_not raise_error 50 | end 51 | end 52 | # test output format 53 | it "produces a Bash script" do 54 | @script.generate.lines.first.chomp.should eq '#!/usr/bin/env bash' 55 | end 56 | # test stripping of empty lines and comments 57 | context "when invoked with :strip_comments = true (the default)" do 58 | it "strips all empty lines and comments from the output" do 59 | lines = @script.generate.lines.to_a 60 | lines[1..lines.count].each do |line| 61 | line.should_not match /^#/ 62 | line.should_not match /^\s+$/ 63 | end 64 | end 65 | end 66 | context "when invoked with :strip_comments = false" do 67 | it "leaves empty lines and comments in the output" do 68 | lines = @script.generate(strip_comments: false).lines.to_a 69 | lines.select{|l| l =~ /^\s+$/}.count.should be > 0 # check empty lines 70 | lines.select{|l| l =~ /^#/}.count.should be > 1 # check comments 71 | end 72 | end 73 | 74 | # test rendering of built-in variables into built-in templates 75 | vars = {create_ramdisk: false, ramdisk_size: 5, 76 | ramdisk_mount: '/secrets', update_os: false} 77 | vars.keys.each do |var| 78 | it "renders template variable :#{var} as Bash variable #{var.upcase}" do 79 | rendered_config = Unpacker.new(Script.new.generate(vars)).config 80 | vars[var].to_s.should eq rendered_config[var.upcase.to_s] 81 | end 82 | end 83 | # test rendering of custom templates 84 | it "renders custom templates into place with correct ERB values" do 85 | @script.data_map = {'/hello.sh' => 'echo Hello, <%= my_name %>.'} 86 | text = @script.generate(my_name: 'H. L. Mencken') 87 | Dir.mktmpdir do |tmp_dir| # do unarchiving in a temp dir 88 | Unpacker.new(text).unpack_to tmp_dir 89 | File.exists?("#{tmp_dir}/hello.sh").should == true 90 | File.read("#{tmp_dir}/hello.sh").should eq 'echo Hello, H. L. Mencken.' 91 | end 92 | end 93 | # test raw file copying 94 | it "copies non-template files directly into the generated archive" do 95 | # insert this test file itself into the BootScript's archive! :-/ 96 | @script.data_map = {File.basename(__FILE__) => File.new(__FILE__)} 97 | Dir.mktmpdir do |tmp_dir| # do unarchiving in a temp dir 98 | target_file = "#{tmp_dir}/#{File.basename(__FILE__)}" 99 | Unpacker.new(@script.generate).unpack_to tmp_dir 100 | File.exists?(target_file).should == true 101 | File.read(target_file).should eq File.read(__FILE__) 102 | end 103 | end 104 | # test return values 105 | context "when invoked without any output destination" do 106 | it "returns the rendered text of the BootScript" do 107 | rendered_text = @script.generate 108 | rendered_text.should be_a String 109 | rendered_text.lines.first.chomp.should eq '#!/usr/bin/env bash' 110 | rendered_text.lines.should include("__ARCHIVE_FOLLOWS__\n") 111 | end 112 | end 113 | context "when invoked with a custom output destination" do 114 | it "returns the number of bytes written to the destination" do 115 | bytes_written, script_size = 0, @script.generate.bytes.count 116 | File.open('/dev/null', 'w') do |outfile| 117 | bytes_written = @script.generate({}, outfile) 118 | end 119 | bytes_written.should be_a Fixnum 120 | bytes_written.should == script_size 121 | end 122 | end 123 | 124 | end 125 | 126 | end 127 | -------------------------------------------------------------------------------- /lib/templates/bootscript.sh.erb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Top-level bootscript 3 | set -e # always stop on errors 4 | test $UID == 0 || (echo "ERROR: must run as root"; exit 1) 5 | 6 | #################################### 7 | #### Step 0 - Configuration 8 | UPDATE_OS='<%= update_os %>' 9 | CREATE_RAMDISK='<%= create_ramdisk %>' 10 | RAMDISK_SIZE='<%= ramdisk_size %>' 11 | RAMDISK_MOUNT='<%= ramdisk_mount %>' 12 | LOG="/var/log/bootscript.log" 13 | PKGS='<%= inst_pkgs %>' 14 | 15 | #################################### 16 | #### Step 1 - Logging control 17 | # Log all output to the log file, in addition to STDOUT 18 | npipe=/tmp/$$.tmp 19 | trap "rm -f $npipe" EXIT 20 | mknod $npipe p 21 | tee <$npipe $LOG & 22 | exec 1>&- 23 | exec 1>$npipe 2>&1 24 | 25 | 26 | #################################### 27 | #### STEP 2 - Detect linux distro 28 | lowercase(){ 29 | echo "$1" | 30 | sed "y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/" 31 | } 32 | OS=`lowercase \`uname\`` 33 | KERNEL=`uname -r` 34 | MACH=`uname -m` 35 | if [ "{$OS}" == "windowsnt" ]; then 36 | OS=windows 37 | elif [ "{$OS}" == "darwin" ]; then 38 | OS=mac 39 | else 40 | OS=`uname` 41 | if [ "${OS}" = "SunOS" ] ; then 42 | OS=Solaris 43 | ARCH=`uname -p` 44 | OSSTR="${OS} ${REV}(${ARCH} `uname -v`)" 45 | elif [ "${OS}" = "AIX" ] ; then 46 | OSSTR="${OS} `oslevel` (`oslevel -r`)" 47 | elif [ "${OS}" = "Linux" ] ; then 48 | if [ -f /etc/redhat-release ] ; then 49 | DistroBasedOn='RedHat' 50 | DIST=`cat /etc/redhat-release |sed s/\ release.*//` 51 | PSUEDONAME=`cat /etc/redhat-release | sed s/.*\(// | sed s/\)//` 52 | REV=`cat /etc/redhat-release | sed s/.*release\ // | sed s/\ .*//` 53 | elif [ -f /etc/SuSE-release ] ; then 54 | DistroBasedOn='SuSe' 55 | PSUEDONAME=`cat /etc/SuSE-release | tr "\n" ' '| sed s/VERSION.*//` 56 | REV=`cat /etc/SuSE-release | tr "\n" ' ' | sed s/.*=\ //` 57 | elif [ -f /etc/mandrake-release ] ; then 58 | DistroBasedOn='Mandrake' 59 | PSUEDONAME=`cat /etc/mandrake-release | sed s/.*\(// | sed s/\)//` 60 | REV=`cat /etc/mandrake-release | sed s/.*release\ // | sed s/\ .*//` 61 | elif [ -f /etc/debian_version ] ; then 62 | DistroBasedOn='Debian' 63 | DIST=`cat /etc/lsb-release | grep '^DISTRIB_ID' | awk -F= '{ print $2 }'` 64 | PSUEDONAME=`cat /etc/lsb-release | grep '^DISTRIB_CODENAME' | awk -F= '{ print $2 }'` 65 | REV=`cat /etc/lsb-release | grep '^DISTRIB_RELEASE' | awk -F= '{ print $2 }'` 66 | fi 67 | if [ -f /etc/UnitedLinux-release ] ; then 68 | DIST="${DIST}[`cat /etc/UnitedLinux-release | tr "\n" ' ' | sed s/VERSION.*//`]" 69 | fi 70 | OS=`lowercase $OS` 71 | DistroBasedOn=`lowercase $DistroBasedOn` 72 | readonly OS 73 | readonly DIST 74 | readonly DistroBasedOn 75 | readonly PSUEDONAME 76 | readonly REV 77 | readonly KERNEL 78 | readonly MACH 79 | fi 80 | fi 81 | 82 | #################################### 83 | #### STEP 3 - update core packages that pose a risk 84 | if [ "$DistroBasedOn" == 'debian' ] ; then 85 | export DEBIAN_FRONTEND=noninteractive 86 | if [ "$UPDATE_OS" == 'true' ] ; then 87 | echo "Upgrading core OS packages..." 88 | apt-get update 89 | if [ -n "${PKGS}" ] ; then 90 | apt-get install -y $PKGS || echo "==> WARNING: Unable to update core system packages!" 91 | fi 92 | fi 93 | fi 94 | 95 | #################################### 96 | #### STEP 4 - Create RAMdisk 97 | if [ "$CREATE_RAMDISK" == 'true' ] ; then 98 | if $(mount | grep "$RAMDISK_MOUNT" >/dev/null 2>&1) ; then 99 | echo "RAMdisk already exists at ${RAMDISK_MOUNT}..." 100 | else 101 | echo "Creating $RAMDISK_SIZE RAMdisk at ${RAMDISK_MOUNT}..." 102 | if [ -e $RAMDISK_MOUNT ] ; then 103 | echo "ERROR: $RAMDISK_MOUNT already exists and is not a RAMdisk!" 104 | exit 4 105 | fi 106 | mkdir -p $RAMDISK_MOUNT 107 | mount -t tmpfs -o size=${RAMDISK_SIZE}M tmpfs $RAMDISK_MOUNT 108 | fi 109 | fi 110 | 111 | 112 | #################################### 113 | #### STEP 5 - Check for uudecode, and attempt to install it if needed 114 | if ! (which uudecode >/dev/null 2>&1) ; then 115 | flavor="$OS / $DIST / $DistroBasedOn" 116 | echo "uudecode not found - will attempt installation for $flavor" 117 | if [ "$OS" == 'linux' ] ; then 118 | if [ "$DistroBasedOn" == 'debian' ] ; then 119 | apt-get -y install sharutils 120 | elif [ "$DistroBasedOn" == 'redhat' ] ; then 121 | yum -y update 122 | # As of RH6, the sharutils package is in the "optional" repo 123 | yum-config-manager --enable rhui-REGION-rhel-server-releases-optional 124 | # As of RH7, the sharutils package is in the "optional" repo 125 | yum-config-manager --enable rhui-REGION-rhel-server-optional 126 | yum -y install sharutils 127 | else 128 | echo "ERROR: Only Debian-derived and Red Hat Linux supported for now :(" 129 | exit 3 130 | fi 131 | fi 132 | fi 133 | 134 | 135 | #################################### 136 | #### STEP 6 - Extract Archive 137 | # Cut the trailing part of this file and pipe it to uudecode and tar 138 | echo "Extracting the included tar archive..." 139 | SCRIPT_PATH="$( cd "$(dirname "$0")" ; pwd -P )/$(basename $0)" 140 | ARCHIVE=`awk '/^__ARCHIVE_FOLLOWS__/ {print NR + 1; exit 0; }' $SCRIPT_PATH` 141 | cd / 142 | tail -n+$ARCHIVE $SCRIPT_PATH | uudecode -o /dev/stdout | tar xovz 143 | echo "Removing ${SCRIPT_PATH}..." 144 | rm -f $SCRIPT_PATH # this script removes itself! 145 | 146 | 147 | <% if defined? startup_command %> 148 | #################################### 149 | #### STEP 6 - Execute startup command 150 | echo "Executing user startup command..." 151 | <% if defined? chef_validation_pem %> 152 | chmod 0744 /usr/local/sbin/chef-install.sh 153 | <% end %> 154 | if ! <%= startup_command %> ; then 155 | echo "Startup command failed. This system may be unhealthy." 156 | 157 | # The startup command failed so this instance is likely to be 158 | # broken in some way. We should mark the instance as unhealthy 159 | # if it's part of an AWS autoscaling group. This relies on the 160 | # AWS CLI tools being installed. 161 | 162 | aws_cli_version=$(aws --version 2>&1 | awk '{print $1}' | awk -F/ '{print $2}') 163 | aws_major=$(echo $aws_cli_version | awk -F. '{print $1}' | sed -e 's/[^0-9]//g') 164 | aws_minor=$(echo $aws_cli_version | awk -F. '{print $2}' | sed -e 's/[^0-9]//g') 165 | if [ "$aws_major" -le "1" -a "$aws_minor" -lt "11" ]; then 166 | echo "The aws-cli package version is out of date; please upgrade your AMI." 167 | else 168 | if ec2_instance_id=$(curl -s http://169.254.169.254/latest/meta-data/instance-id) ; then 169 | ec2_region=$(curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed -e s'/.$//') 170 | which aws >/dev/null && \ 171 | aws autoscaling describe-auto-scaling-instances \ 172 | --instance-ids $ec2_instance_id \ 173 | --region $ec2_region \ 174 | | grep -F $ec2_instance_id >/dev/null && 175 | aws autoscaling set-instance-health \ 176 | --instance-id $ec2_instance_id \ 177 | --health-status Unhealthy \ 178 | --no-should-respect-grace-period \ 179 | --region $ec2_region 180 | fi 181 | 182 | fi 183 | 184 | exit 1 185 | fi 186 | <% end %> 187 | exit 0 188 | 189 | 190 | #################################### 191 | #### Boot data archive - this gets extracted with `tail`, and 192 | #### piped through `uudecode` and `tar` in Step 5, above 193 | __ARCHIVE_FOLLOWS__ 194 | -------------------------------------------------------------------------------- /lib/bootscript/script.rb: -------------------------------------------------------------------------------- 1 | module Bootscript 2 | 3 | require 'fileutils' 4 | require 'erubis' 5 | require 'json' 6 | require 'tmpdir' 7 | require 'zlib' 8 | require 'archive/tar/minitar' 9 | require 'zip' 10 | 11 | # Main functional class. Models and builds a self-extracting Bash/TAR file. 12 | class Script 13 | 14 | # A Hash of data sources to be written onto the boot target's filesystem. 15 | # Each (String) key is a path to the desired file on the boot target. 16 | # Each value can be a String (treated as ERB), or Object with a read method. 17 | # Any Ruby File objects with extension ".ERB" are also processed as ERB. 18 | attr_accessor :data_map 19 | 20 | # Standard Ruby Logger, overridden by passing :logger to {#initialize} 21 | attr_reader :log 22 | 23 | # constructor - configures the AWS S3 connection and logging 24 | # @param logger [::Logger] - a standard Ruby logger 25 | def initialize(logger = nil) 26 | @log ||= logger || Bootscript.default_logger 27 | @data_map = Hash.new 28 | @vars = Hash.new 29 | end 30 | 31 | # Generates the BootScript contents by interpreting the @data_map 32 | # based on erb_vars. If destination has a write() method, 33 | # the data is streamed there line-by-line, and the number of bytes written 34 | # is returned. Otherwise, the BootScript contents are returned as a String. 35 | # In the case of streaming output, the destination must be already opened. 36 | # @param erb_vars [Hash] Ruby variables to interpolate into all templates 37 | # @param destination [IO] a Ruby object that responds to write(String) 38 | # @return [Fixnum] the number of bytes written to the destination, or 39 | # @return [String] the text of the rendered script, if destination is nil 40 | def generate(erb_vars = {}, destination = nil) 41 | # Set state / instance variables, used by publish() and helper methods 42 | @vars = Bootscript.merge_platform_defaults(erb_vars) 43 | output = destination || StringIO.open(@script_data = "") 44 | @bytes_written = 0 45 | if Bootscript.windows?(@vars) 46 | @bytes_written += output.write(render_erb_text(File.read( 47 | "#{File.dirname(__FILE__)}/../templates/windows_header.ps1.erb" 48 | ))) 49 | end 50 | write_bootscript(output) # streams the script part line-by-line 51 | write_uuencoded_archive(output) # streams the archive line-by-line 52 | if Bootscript.windows?(@vars) 53 | @bytes_written += output.write(render_erb_text(File.read( 54 | "#{File.dirname(__FILE__)}/../templates/windows_footer.ps1.erb" 55 | ))) 56 | end 57 | output.close unless destination # (close StringIO if it was opened) 58 | return (destination ? @bytes_written : @script_data) 59 | end 60 | 61 | private 62 | 63 | # Streams the bootscript to destination and updates @bytes_written 64 | def write_bootscript(destination) 65 | # If streaming, send the top-level script line-by-line from memory 66 | if Bootscript.windows?(@vars) 67 | template_path = Bootscript::WINDOWS_TEMPLATE 68 | else 69 | template_path = Bootscript::UNIX_TEMPLATE 70 | end 71 | template = File.read(template_path) 72 | template = strip_shell_comments(template) if @vars[:strip_comments] 73 | @log.debug "Rendering boot script to #{destination}..." 74 | render_erb_text(template).each_line do |ln| 75 | destination.write ln 76 | @bytes_written += ln.bytes.count 77 | end 78 | end 79 | 80 | # Streams the uuencoded archive to destination, updating @bytes_written 81 | def write_uuencoded_archive(destination) 82 | @log.debug "Writing #{@vars[:platform]} archive to #{destination}..." 83 | if Bootscript.windows?(@vars) 84 | @bytes_written += destination.write("$archive = @'\n") 85 | write_windows_archive(destination) 86 | @bytes_written += destination.write("'@\n") 87 | else # :platform = 'unix' 88 | @bytes_written += destination.write("begin-base64 0600 bootstrap.tbz\n") 89 | write_unix_archive(destination) 90 | @bytes_written += destination.write("====\n") # (base64 footer) 91 | end 92 | end 93 | 94 | # Streams a uuencoded TGZ archive to destination, updating @bytes_written 95 | def write_unix_archive(destination) 96 | begin 97 | uuencode = UUWriter.new(destination) 98 | gz = Zlib::GzipWriter.new(uuencode) 99 | tar = Archive::Tar::Minitar::Writer.open(gz) 100 | render_data_map_into(tar) 101 | ensure 102 | tar.close 103 | gz.close 104 | @bytes_written += uuencode.bytes_written 105 | end 106 | end 107 | 108 | # Streams a uuencoded ZIP archive to destination, updating @bytes_written 109 | def write_windows_archive(destination) 110 | Dir.mktmpdir do |dir| 111 | zip_path = "#{dir}/archive.zip" 112 | zipfile = File.open(zip_path, 'wb') 113 | Zip::OutputStream.open(zipfile) {|zip| render_data_map_into(zip)} 114 | zipfile.close 115 | @log.debug "zipfile = #{zip_path}, length = #{File.size zip_path}" 116 | File.open(zip_path, 'rb') do |zipfile| 117 | @bytes_written += destination.write([zipfile.read].pack 'm') 118 | end 119 | end 120 | end 121 | 122 | # renders each data map item into an 'archive', which must be either an 123 | # Archive::Tar::Minitar::Writer (if unix), or a Zip::OutputStream (windows) 124 | def render_data_map_into(archive) 125 | full_data_map.each do |remote_path, item| 126 | if item.is_a? String # case 1: data item is a String 127 | @log.debug "Rendering ERB data (#{item[0..16]}...) into archive" 128 | data = render_erb_text(item) 129 | input = StringIO.open(data, 'r') 130 | size = data.bytes.count 131 | elsif item.is_a?(File) # case 2: data item is an ERB file 132 | if item.path.upcase.sub(/\A.*\./,'') == 'ERB' 133 | @log.debug "Rendering ERB file #{item.path} into archive" 134 | data = render_erb_text(item.read) 135 | input = StringIO.open(data, 'r') 136 | size = data.bytes.count 137 | else # case 3: data item is a regular File 138 | @log.debug "Copying data from #{item.inspect} into archive" 139 | input = item 140 | size = File.stat(item).size 141 | end 142 | else # case 4: Error 143 | raise ArgumentError.new("cannot process item: #{item}") 144 | end 145 | if Bootscript.windows?(@vars) 146 | archive.put_next_entry remote_path 147 | archive.write input.read 148 | else 149 | opts = {mode: 0600, size: size, mtime: Time.now} 150 | archive.add_file_simple(remote_path, opts) do |output| 151 | while data = input.read(512) ; output.write data end 152 | end 153 | end 154 | end 155 | end 156 | 157 | # merges the @data_map with the Chef built-ins, as-needed 158 | def full_data_map 159 | Bootscript::Chef.included?(@vars) ? 160 | @data_map.merge(Bootscript::Chef.files(@vars)) : @data_map 161 | end 162 | 163 | # renders erb_text, using @vars 164 | def render_erb_text(erb_text) 165 | Erubis::Eruby.new(erb_text).result(@vars) 166 | end 167 | 168 | # strips all empty lines and lines beginning with # from text 169 | # does NOT touch the first line of text 170 | def strip_shell_comments(text) 171 | lines = text.lines.to_a 172 | return text if lines.count < 2 173 | lines.first + lines[1..lines.count]. 174 | reject{|l| (l =~ /^\s*#/) || (l =~ /^\s+$/)}.join('') 175 | end 176 | end 177 | 178 | end 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Bootscript 2 | ================ 3 | Constructs a self-extracting archive, wrapped in a Bash script (or Windows batch script), for securely initializing cloud systems. 4 | 5 | *BETA VERSION - needs more functional testing and broader OS support* 6 | 7 | ---------------- 8 | What is it? 9 | ---------------- 10 | The bootscript gem enables simple creation of a self-extracting "TAR archive within a Bash script", which can be composed from any set of binary or text files -- including support for ERB templates. Any Hash of Ruby values can be interpolated into these templates when they are rendered, and the resulting "boot script" (complete with the base64-encoded archive at the end) can be invoked on nearly any Unix or Windows system to: 11 | 12 | * create a RAMdisk for holding the archive contents, which are presumably secrets (this step is optional, and does not yet work on Windows) 13 | * extract the archived files 14 | * delete itself 15 | * execute a user-specified command for further configuration 16 | 17 | An extra, optional submodule is also supplied that leverages the above process to install Chef (omnibus), assign arbitrary node attributes (using the same Hash mentioned above), and kick off convergence for a given run list. 18 | 19 | 20 | ---------------- 21 | Why is it? 22 | ---------------- 23 | * makes specification of complex, cross-platform boot data simple and portable. 24 | * simplifies initial Chef setup 25 | 26 | ---------------- 27 | Where is it? (Installation) 28 | ---------------- 29 | Install the gem and its dependencies from RubyGems: 30 | 31 | gem install bootscript 32 | 33 | 34 | ---------------- 35 | How is it [done]? (Usage) 36 | ---------------- 37 | Call the gem's main public method: `Bootscript.generate()`. It accepts a Hash of template variables as its first argument, which is passed directly to any ERB template files as they render. All the data in the Hash is available to the templates, but some of the key-value pairs also control the gem's rendering behavior, as demonstrated in the following examples. (There's also a [list of such variables](ERB_VARS.md).) 38 | 39 | 40 | ### Simplest - make a RAMdisk 41 | 42 | require 'bootscript' 43 | script = Bootscript.generate( 44 | create_ramdisk: true, # default mount is /etc/secrets 45 | startup_command: 'df -h /etc/secrets' # show the RAMdisk's free space 46 | ) 47 | puts "Now run this as root on any unix node that has `bash` installed:" 48 | puts script 49 | 50 | 51 | ### Simple - render and install a bash script, then run it 52 | 53 | To include some files inside the script's archive, create a "data map", which is a Hash that maps locations on the boot target's filesystem to the values that will be written there when the script is run. The following example generates a script that, when executed, writes some text into the file `/root/hello.sh`. 54 | 55 | # Define a simple shell script, to be written to the node's filesystem: 56 | data_map = {'/root/hello.sh' => 'echo Hello, <%= my_name %>.'} 57 | puts Bootscript.generate({ 58 | my_name: ENV['USER'], # evaluated now, at generation 59 | startup_command: 'sh /root/hello.sh', # run on the node, after unarchiving 60 | }, data_map 61 | ) 62 | 63 | _(Can you guess what it will print on the node that runs the script?)_ 64 | 65 | 66 | ### Chef support, using the included templates (single node) 67 | 68 | The software's Chef support includes some predefined template files that will install the Chef client sofware, and then kick off the convergence process. These templates are automatically included into the boot script when you pass a `:chef_validation_pem` to the `generate()` method, so no data map is required for this example. 69 | 70 | The two Chef secrets are passed directly to `generate`, so they should be read from the filesystem if necessary... 71 | 72 | VALIDATION_CERT = File.read "#{ENV['HOME']}/.chef/myorg-validator.pem" 73 | DATABAG_SECRET = File.read "#{ENV['HOME']}/.chef/myorg-databag-secret.txt" 74 | 75 | require 'uuid' # Make some unique boot data 76 | NODE_UUID = UUID.generate # for just this one node... 77 | puts Bootscript.generate( 78 | logger: Logger.new(STDOUT), # Monitor progress 79 | create_ramdisk: true, # make a RAMdisk 80 | chef_validation_pem: VALIDATION_CERT, # the data, not the path! 81 | chef_databag_secret: DATABAG_SECRET, # same here - the secret data 82 | chef_attributes: { # ALWAYS USE STRINGS FOR CHEF ATTRIBUTE KEYS! 83 | 'run_list' => 'role[my_app_server]', 84 | 'chef_client' => { 85 | 'config' => { 86 | 'node_name' => "myproject-myenv-#{NODE_UUID}", 87 | 'chef_server_url' => "https://api.opscode.com/organizations/myorg", 88 | 'validation_client_name' => "myorg-validator", 89 | } 90 | } 91 | } 92 | ) 93 | 94 | 95 | The validation certificate and data bag secrets will be saved in a `chef` directory below the RAMdisk mount point, then symlinked into `/etc/chef`. You should use this technique for all the files you put into the `data_map` that contain secrets! 96 | 97 | ### Chef support, with the node name determined by the node 98 | 99 | This is just like the previous example, only first you create a ruby file that will run on the node at boot time, to compute the node name. This can also be a template, like `/tmp/set_node_name.rb.erb`: 100 | 101 | unless node_name 102 | # filled in at publish() time by the bootstrap gem 103 | name = '<%= project %>.<%= stage %>.<%= tier %>' 104 | require 'ohai' 105 | ohai = Ohai::System.new 106 | ohai.all_plugins 107 | if ohai[:ec2] && ohai[:ec2][:instance_id] 108 | name = "#{name}.#{ohai[:ec2][:instance_id]}" 109 | else 110 | name = "#{name}.#{rand(2**(0.size * 8 -2) -1)}" 111 | end 112 | puts "Setting node name to #{name}..." 113 | node_name name 114 | end 115 | 116 | Now tell the boot script to put the ruby file where chef-client will pick it up (thanks, Opscode!). Note that when including an ERB file into the boot archive, the value in the data map should be an existing Ruby File object, not a String: 117 | 118 | data_map = { 119 | '/etc/chef/client.d/set_node_name.rb' => File.new("/tmp/set_node_name.rb.erb") 120 | } 121 | 122 | Finally, generate *without* an explicit node name, but filling in the other values that are known at the time. Don't forget to pass the data map as the second argument to `generate()`. 123 | 124 | PROJECT, STAGE, TIER = 'myapp', 'testing', 'db' 125 | script = Bootscript.generate({ 126 | project: PROJECT, # As before, these values are rendered 127 | stage: STAGE, # into the above ruby template. 128 | tier: TIER, 129 | chef_attributes: { 130 | 'run_list' => 'role[my_app_server]', 131 | 'chef_client' => { # NOTE - no node_name passed here, 132 | 'config' => { # but the rest is the same... 133 | 'chef_server_url' => "https://api.opscode.com/organizations/myorg", 134 | 'validation_client_name' => "myorg-validator", 135 | } 136 | } 137 | }, 138 | chef_validation_pem: VALIDATION_CERT, 139 | chef_databag_secret: DATABAG_SECRET, 140 | }, data_map) 141 | 142 | 143 | ---------------- 144 | *Known Limitations / Bugs* 145 | ---------------- 146 | * bash and tar are required on Unix boot targets 147 | * Powershell is required on Windows boot targets 148 | * bash, tar and uudecode are required to run the tests 149 | 150 | 151 | ---------------- 152 | Who is it? (Contribution) 153 | ---------------- 154 | This Gem was created at [Medidata][] by Benton Roberts _(broberts@mdsol.com)_ 155 | 156 | The project is still in its early stages. Helping hands are appreciated. 157 | 158 | 1) Install project dependencies. 159 | 160 | gem install rake bundler 161 | 162 | 2) Fetch the project code and bundle up... 163 | 164 | git clone https://github.com/mdsol/bootscript.git 165 | cd bootscript 166 | bundle 167 | 168 | 3) Run the tests: 169 | 170 | bundle exec rake 171 | 172 | 4) Autotest while you work: 173 | 174 | bundle exec autotest 175 | 176 | 177 | -------- 178 | [Medidata]: http://mdsol.com 179 | --------------------------------------------------------------------------------