#!/usr/bin/env ruby require 'gli' begin # XXX: Remove this begin/rescue before distributing your app require 'wellcar' rescue LoadError => e STDERR.puts e.message STDERR.puts "In development, you need to use `bundle exec bin/wellcar` to run your app" STDERR.puts "At install-time, RubyGems will make sure lib, etc. are in the load path" STDERR.puts "Feel free to remove this message from bin/wellcar now" exit 64 end class CommandBase attr_accessor :ruby_version, :app_name, :use_bundler_2, :github_account, :domain_name def core_docker_templates master_key = File.exist?("config/credentials/production.key") ? File.read("config/credentials/production.key") : nil [ Wellcar::Templates::Dockerfile.new(ruby_version, app_name, use_bundler_2), Wellcar::Templates::DockerStack.new(app_name, github_account), Wellcar::Templates::EnvDevelopmentDatabase.new(app_name), Wellcar::Templates::EnvDevelopmentWeb.new, Wellcar::Templates::EnvProductionWeb.new(master_key), Wellcar::Templates::EnvProductionDatabase.new(app_name), Wellcar::Templates::Dockerignore.new, Wellcar::Templates::DockerEntrypoint.new, Wellcar::Templates::BuildProductionImage.new(app_name, github_account), Wellcar::Templates::ReverseProxyConf.new(domain_name) ] end def create_directories FileUtils.mkdir_p "config" FileUtils.mkdir_p "bin" FileUtils.mkdir_p ".env/development" FileUtils.mkdir_p ".env/production" end def write_files files_before_docker = Dir['*'] system "curl -o bin/wait-for https://raw.githubusercontent.com/mrako/wait-for/master/wait-for" docker_files.each(&:write) puts "Wellcar created:\n#{Dir['*'] - files_before_docker}\n" end def create_wellcar_folder return if Dir.exist? ".wellcar" Dir.mkdir ".wellcar" FileUtils.touch ".wellcar/.keep" end def clean_docker # Make sure that no pre-existing containers are running. Although # very unlikely, it's worth being thorough. This makes the command # slow but for the time being this ensures the containers and images # are as expected. exit 8 unless system "docker-compose down --remove-orphans" removable_volumes = ["#{app_name}_gem_cache", "#{app_name}_node_modules"] volumes_to_remove = %x(docker volume ls --format='{{.Name}}') .split("\n") & removable_volumes return if volumes_to_remove.empty? puts "Removing the following volumes: #{volumes_to_remove.join(", ")}" system "docker volume rm #{volumes_to_remove.join(" ")}" end def build_development_environment exit 128 unless system "docker-compose build web webpack-dev-server database" end end class NewCommand < CommandBase def initialize(app_name, github_account, domain_name) self.app_name = app_name self.domain_name = domain_name self.github_account = github_account self.ruby_version = "2.6.6" self.use_bundler_2 = true end def docker_files @files ||= core_docker_templates.push( Wellcar::Templates::DockerfileInit.new(ruby_version, app_name), Wellcar::Templates::DockerCompose.new(app_name, github_account, Wellcar::Templates::DockerCompose::INIT_TEMPLATE) ) end def try_full_nuke(nuke) if nuke && Dir.exists?(app_name) puts "*** Destroying the existing folder #{app_name} ***" FileUtils.rm_rf app_name elsif Dir.exists? app_name puts "Cannot overwrite existing folder #{Dir.pwd}/#{app_name}" exit 1 end end def within_app_folder(&block) raise NotImplemented if block.nil? Dir.mkdir app_name Dir.chdir(app_name) do yield end end def perform_setup # Create app and install webpacker exit 16 unless system "docker-compose up --build new_rails" exit 32 unless system "docker-compose up --build bundle" exit 64 unless system "docker-compose up --build install_webpacker" puts "Rails app created; initializing for Docker\n" end def tidy_up_setup # remove dockerfile.init and docker-compose.yml with initialisation services %w(Dockerfile.init docker-compose.yml config/database.yml).each {|f| File.delete(f) if File.file?(f) } # Create docker-compose.yml file Wellcar::Templates::DockerCompose.new(app_name, github_account).write Wellcar::Templates::DatabaseYaml.new(app_name).write end def call(options, args) # Here we want to create a new rails app that is set up with docker. # We use `rails new` within a `docker run` command but skip a lot of set up so # that further setup steps can be taken later on. # What I don't know right now is how steps that are skipped at the start can be # initiated in an existing app. # # My list of pre-requisites for a Rails app are: # * RSpec # * Webpack + yarn # * Rubocop # * PostgreSQL # * Redis # * FactoryBot # * Capybara with a real webdriver # * Devise try_full_nuke options[:full_nuke] within_app_folder do create_wellcar_folder create_directories write_files clean_docker perform_setup tidy_up_setup build_development_environment end puts <<-FINISHED Your new Rails application "#{app_name}" has been created with Docker! Don't forget to commit everything to git. You can use wellcar to interact with the Docker containers and the Rails application as you develop your app. Run `wellcar` for help with commands. FINISHED end end class DockCommand < CommandBase def initialize(github_account, domain_name) self.github_account = github_account self.domain_name = domain_name end def interpret_app_name app_name = File.read("./config/application.rb") .split("\n") .select {|line| line =~ /^module / } .first &.split &.last &.downcase return app_name unless app_name.nil? puts "Cannot determine app name from config/application.rb. wellcar expects to find one module in this file named for the application." exit 5 end def interpret_ruby_version File.file?(".ruby_version") ? File.read(".ruby_version").split.first : "2.6.6" end def interpret_bundler_2 b2 = File.file?("Gemfile.lock") && File.read("Gemfile.lock").split("BUNDLED WITH")&.last.strip >= "2.0.0" puts "Install bundler 2? #{b2}" b2 end def determine_variables self.app_name = interpret_app_name self.ruby_version = interpret_ruby_version self.use_bundler_2 = interpret_bundler_2 end def prepare_templates docker_files end def docker_files @files ||= core_docker_templates.push(Wellcar::Templates::DockerCompose.new(app_name, github_account)) end def check_existing_files(force_new) files_exist = docker_files.select {|file| file.exist? }.any? if force_new && files_exist puts "--force-new set. Overwriting **all** existing docker files." if Dir.exist? ".env" label = Time.new.strftime("%Y%m%d%H%M%S") puts ".env will be backed up to .wellcar/env-#{label}" destination = ".wellcar/env-#{label}" FileUtils.copy_entry ".env", destination exit unless Dir.exist? destination end elsif files_exist puts "Found existing docker files. Please check your app source tree before trying to dockerise this app with wellcar again." exit 3 end end def update_database_config Wellcar::Templates::DatabaseYaml.new(app_name).update puts <<-DB ... updated development settings for database.yml. Now using dockerised PostgreSQL server. Previous config saved as config/database.pre_docker.yml DB end # This does not currently work, as it is called before bundle is available. I'm debating how # to include this "gem tidying" aspect of adding Docker to an existing project. def tidy_gemfile %w(sqlite3 figaro dotenv).each {|gem| system "docker-compose run --rm bin/bundle remove #{gem}" } system "docker-compose run --rm bin/bundle add pg" unless system("docker-compose run --rm bin/bundle info pg") end def call(options, args) create_wellcar_folder determine_variables prepare_templates check_existing_files options[:"force-new"] create_directories write_files update_database_config clean_docker # This assumes bin/bundle is available, which will not be the case. # The gem set up done in this call should be part of a Dockerfile, but needs # a script to manage some conditional logic. # tidy_gemfile build_development_environment puts <<-DONE Your Rails app has been dockerised! You can use wellcar to interact with the Docker containers and the Rails application as you develop your app. Run `wellcar` for help with commands. DONE end end class App extend GLI::App program_desc 'Wellcar makes development of dockerised Rails apps easier' version Wellcar::VERSION subcommand_option_handling :normal arguments :strict desc 'Creates a new docked Rails app' arg 'name' command :new do |c| c.desc "Force-delete if folder matching exists" c.switch "full-nuke" c.flag [:d, :domain], desc: "Used to set a domain name in configuration files, e.g. nginx config", default: "" c.flag "github-account", required: true, desc: "Sets the GitHub account used in building Docker image paths (which assumes images are hosted as GitHub Packages)" c.action do |global_options, options, args| NewCommand.new(args[0], options['github-account'], options[:domain]).call options, args end end desc 'Adds docker config to an existing Rails application' command :dock do |c| c.switch "force-new", desc: 'Forces all existing docker files to be overwritten and images to be rebuilt' c.flag [:d, :domain], desc: "Used to set a domain name in configuration files, e.g. nginx config", default: "" c.flag "github-account", desc: "Sets the GitHub account used in building Docker image paths (which assumes images are hosted as GitHub Packages)", required: true c.action do |global_options, options, args| DockCommand.new(options['github-account'], options[:domain]).call options, args end end desc 'Builds an image for production and deploys to a named remote server' command :deploy do |c| # Build the production image # Save the image locally # Set DOCKER_HOST to connect to remote server # Copy onto remote host to load into docker on remote host # Perhaps like: # docker save mycontainerimage |\ # gzip |\ # ssh root@203.0.113.1 'gunzip | docker load' # Or: # docker save | gzip | scp -J : # Run docker stack deploy -c docker-stack.yml # Delete image on remote host? end desc 'Checks that a named remote server can run the production build' command :check_server do |c| c.action do |global_options, options, args| # Removes old versions of Docker # sudo apt-get remove docker docker-engine docker.io containerd runc # Assumes an Ubuntu server # Update repo # sudo apt-get update # Install required libraries # sudo apt-get install \ # sudo apt-get install \ # apt-transport-https \ # ca-certificates \ # curl \ # gnupg-agent \ # software-properties-common # Add Docker repo public key # curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - # Check the key visually matches 9DC8 5822 9FC7 DD38 854A E2D8 8D81 803C 0EBF CD88 # sudo apt-key fingerprint 0EBFCD88 # Add the repository that matches the Ubuntu distro # sudo add-apt-repository \ # "deb [arch=amd64] https://download.docker.com/linux/ubuntu \ # $(lsb_release -cs) \ # stable" # Update again # sudo apt-get update # Install Docker engine # sudo apt-get install docker-ce docker-ce-cli containerd.io # Test Docker is installed properly # sudo docker run --rm hello-world # sudo docker images --all --format='{{.ID}} {{.Name}}'|\ # grep hello-world|\ # cut -d ' ' -f 1|\ # xargs docker rmi # Add user to docker group # sudo usermod -a -G docker end end desc 'Runs bundle within a container' arg_name 'Pass commands through to bundler. Note that to pass args to bundler, use `--` followed by the args, e.g. `wellcar bundle -- --help ' command :bundle do |c| c.desc 'Set the container name to run bundle in' c.default_value 'web' c.arg_name 'name' c.flag [:c, :container] c.action do |global_options,options,args| container = options[:c] command = "COMPOSE_FILE=#{global_options[:c]} docker-compose run #{container} bundle " command << args * ' ' puts "Full command: #{command}" system command if $?.exitstatus != 0 puts "Unable to call bundle in #{container} successfully" else puts "bundle command ran in #{container}#{args.any? ? " with #{args}" : ""}" end end end desc 'Rebuilds containers' arg_name 'command' command :build do |c| c.action do |global_options, options, args| system("COMPOSE_FILE=#{global_options[:c]} docker-compose build") end end pre do |global,command,options,args| # Pre logic here # Return true to proceed; false to abort and not call the # chosen command # Use skips_pre before a command to skip this block # on that command only true end post do |global,command,options,args| # Post logic here # Use skips_post before a command to skip this # block on that command only end on_error do |exception| # Error logic here # return false to skip default error handling true end end exit App.run(ARGV)