From cf36d184f41b5bfc0c63d6c8409b05cca2eb67ee Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 11 Feb 2018 18:40:57 +0100 Subject: [PATCH] Interactive `rake mastodon:setup` task (#6451) * Add better CLI prompt * Add rake mastodon:setup interactive wizard * Test db/redis/smtp configurations and add admin user at the end * Test database connection even when database does not exist yet --- Gemfile | 2 + Gemfile.lock | 26 +++ lib/tasks/mastodon.rake | 408 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 414 insertions(+), 22 deletions(-) diff --git a/Gemfile b/Gemfile index d1c00b498..e219f5159 100644 --- a/Gemfile +++ b/Gemfile @@ -76,6 +76,8 @@ gem 'simple-navigation', '~> 4.0' gem 'simple_form', '~> 3.4' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'strong_migrations' +gem 'tty-command' +gem 'tty-prompt' gem 'twitter-text', '~> 1.14' gem 'tzinfo-data', '~> 1.2017' gem 'webpacker', '~> 3.0' diff --git a/Gemfile.lock b/Gemfile.lock index b82fc49a6..2131afa65 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -173,6 +173,7 @@ GEM faraday multi_json encryptor (3.0.0) + equatable (0.5.0) erubi (1.7.0) et-orbi (1.0.8) tzinfo @@ -224,6 +225,7 @@ GEM hashie (3.5.7) highline (1.7.10) hiredis (0.6.1) + hitimes (1.2.6) hkdf (0.3.0) htmlentities (4.3.4) http (3.0.0) @@ -312,6 +314,7 @@ GEM msgpack (1.1.0) multi_json (1.12.2) multipart-post (2.0.0) + necromancer (0.4.0) net-scp (1.2.1) net-ssh (>= 2.6.5) net-ssh (4.2.0) @@ -356,6 +359,9 @@ GEM parallel parser (2.4.0.2) ast (~> 2.3) + pastel (0.7.2) + equatable (~> 0.5.0) + tty-color (~> 0.4.0) pg (0.21.0) pghero (1.7.0) activerecord @@ -550,6 +556,23 @@ GEM thread (0.2.2) thread_safe (0.3.6) tilt (2.0.8) + timers (4.1.2) + hitimes + tty-color (0.4.2) + tty-command (0.7.0) + pastel (~> 0.7.0) + tty-cursor (0.5.0) + tty-prompt (0.15.0) + necromancer (~> 0.4.0) + pastel (~> 0.7.0) + timers (~> 4.0) + tty-cursor (~> 0.5.0) + tty-reader (~> 0.2.0) + tty-reader (0.2.0) + tty-cursor (~> 0.5.0) + tty-screen (~> 0.6.4) + wisper (~> 2.0.0) + tty-screen (0.6.4) twitter-text (1.14.7) unf (~> 0.1.0) tzinfo (1.2.4) @@ -579,6 +602,7 @@ GEM websocket-driver (0.6.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.3) + wisper (2.0.0) xpath (2.1.0) nokogiri (~> 1.3) @@ -684,6 +708,8 @@ DEPENDENCIES simplecov (~> 0.14) sprockets-rails (~> 3.2) strong_migrations + tty-command + tty-prompt twitter-text (~> 1.14) tzinfo-data (~> 1.2017) uglifier (~> 3.2) diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake index 486c035de..e144621e5 100644 --- a/lib/tasks/mastodon.rake +++ b/lib/tasks/mastodon.rake @@ -4,6 +4,362 @@ require 'optparse' require 'colorize' namespace :mastodon do + desc 'Configure the instance for production use' + task :setup do + prompt = TTY::Prompt.new + env = {} + + begin + prompt.say('Your instance is identified by its domain name. Changing it afterward will break things.') + env['LOCAL_DOMAIN'] = prompt.ask('Domain name:') do |q| + q.required true + q.modify :strip + q.validate(/\A[a-z0-9\.\-]+\z/i) + q.messages[:valid?] = 'Invalid domain. If you intend to use unicode characters, enter punycode here' + end + + prompt.say "\n" + + prompt.say('Single user mode disables registrations and redirects the landing page to your public profile.') + env['SINGLE_USER_MODE'] = prompt.yes?('Do you want to enable single user mode?', default: false) + + %w(SECRET_KEY_BASE PAPERCLIP_SECRET OTP_SECRET).each do |key| + env[key] = SecureRandom.hex(64) + end + + vapid_key = Webpush.generate_key + + env['VAPID_PRIVATE_KEY'] = vapid_key.private_key + env['VAPID_PUBLIC_KEY'] = vapid_key.public_key + + prompt.say "\n" + + using_docker = prompt.yes?('Are you using Docker to run Mastodon?') + db_connection_works = false + + prompt.say "\n" + + loop do + env['DB_HOST'] = prompt.ask('PostgreSQL host:') do |q| + q.required true + q.default using_docker ? 'db' : '/var/run/postgresql' + q.modify :strip + end + + env['DB_PORT'] = prompt.ask('PostgreSQL port:') do |q| + q.required true + q.default 5432 + q.convert :int + end + + env['DB_NAME'] = prompt.ask('Name of PostgreSQL database:') do |q| + q.required true + q.default using_docker ? 'postgres' : 'mastodon_production' + q.modify :strip + end + + env['DB_USER'] = prompt.ask('Name of PostgreSQL user:') do |q| + q.required true + q.default using_docker ? 'postgres' : 'mastodon' + q.modify :strip + end + + env['DB_PASS'] = prompt.ask('Password of PostgreSQL user:') do |q| + q.echo false + end + + # The chosen database may not exist yet. Connect to default database + # to avoid "database does not exist" error. + db_options = { + adapter: :postgresql, + database: 'postgres', + host: env['DB_HOST'], + port: env['DB_PORT'], + user: env['DB_USER'], + password: env['DB_PASS'], + } + + begin + ActiveRecord::Base.establish_connection(db_options) + ActiveRecord::Base.connection + prompt.ok 'Database configuration works! 🎆' + db_connection_works = true + break + rescue StandardError => e + prompt.error 'Database connection could not be established with this configuration, try again.' + prompt.error e.message + break unless prompt.yes?('Try again?') + end + end + + prompt.say "\n" + + loop do + env['REDIS_HOST'] = prompt.ask('Redis host:') do |q| + q.required true + q.default using_docker ? 'redis' : 'localhost' + q.modify :strip + end + + env['REDIS_PORT'] = prompt.ask('Redis port:') do |q| + q.required true + q.default 6379 + q.convert :int + end + + redis_options = { + host: env['REDIS_HOST'], + port: env['REDIS_PORT'], + driver: :hiredis, + } + + begin + redis = Redis.new(redis_options) + redis.ping + prompt.ok 'Redis configuration works! 🎆' + break + rescue StandardError => e + prompt.error 'Redis connection could not be established with this configuration, try again.' + prompt.error e.message + break unless prompt.yes?('Try again?') + end + end + + prompt.say "\n" + + if prompt.yes?('Do you want to store uploaded files on the cloud?', default: false) + case prompt.select('Provider', ['Amazon S3', 'Wasabi', 'Minio']) + when 'Amazon S3' + env['S3_ENABLED'] = 'true' + env['S3_PROTOCOL'] = 'https' + + env['S3_BUCKET'] = prompt.ask('S3 bucket name:') do |q| + q.required true + q.default "files.#{env['LOCAL_DOMAIN']}" + q.modify :strip + end + + env['S3_REGION'] = prompt.ask('S3 region:') do |q| + q.required true + q.default 'us-east-1' + q.modify :strip + end + + env['S3_HOSTNAME'] = prompt.ask('S3 hostname:') do |q| + q.required true + q.default 's3-us-east-1.amazonaws.com' + q.modify :strip + end + + env['AWS_ACCESS_KEY_ID'] = prompt.ask('S3 access key:') do |q| + q.required true + q.modify :strip + end + + env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('S3 secret key:') do |q| + q.required true + q.modify :strip + end + when 'Wasabi' + env['S3_ENABLED'] = 'true' + env['S3_PROTOCOL'] = 'https' + env['S3_REGION'] = 'us-east-1' + env['S3_HOSTNAME'] = 's3.wasabisys.com' + env['S3_ENDPOINT'] = 'https://s3.wasabisys.com/' + + env['S3_BUCKET'] = prompt.ask('Wasabi bucket name:') do |q| + q.required true + q.default "files.#{env['LOCAL_DOMAIN']}" + q.modify :strip + end + + env['AWS_ACCESS_KEY_ID'] = prompt.ask('Wasabi access key:') do |q| + q.required true + q.modify :strip + end + + env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Wasabi secret key:') do |q| + q.required true + q.modify :strip + end + when 'Minio' + env['S3_ENABLED'] = 'true' + env['S3_PROTOCOL'] = 'https' + env['S3_REGION'] = 'us-east-1' + + env['S3_ENDPOINT'] = prompt.ask('Minio endpoint URL:') do |q| + q.required true + q.modify :strip + end + + env['S3_PROTOCOL'] = env['S3_ENDPOINT'].start_with?('https') ? 'https' : 'http' + env['S3_HOSTNAME'] = env['S3_ENDPOINT'].gsub(/\Ahttps?:\/\//, '') + + env['S3_BUCKET'] = prompt.ask('Minio bucket name:') do |q| + q.required true + q.default "files.#{env['LOCAL_DOMAIN']}" + q.modify :strip + end + + env['AWS_ACCESS_KEY_ID'] = prompt.ask('Minio access key:') do |q| + q.required true + q.modify :strip + end + + env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Minio secret key:') do |q| + q.required true + q.modify :strip + end + end + + if prompt.yes?('Do you want to access the uploaded files from your own domain?') + env['S3_CLOUDFRONT_HOST'] = prompt.ask('Domain for uploaded files:') do |q| + q.required true + q.default "files.#{env['LOCAL_DOMAIN']}" + q.modify :strip + end + end + end + + prompt.say "\n" + + loop do + env['SMTP_SERVER'] = prompt.ask('SMTP server:') do |q| + q.required true + q.default 'smtp.mailgun.org' + q.modify :strip + end + + env['SMTP_PORT'] = prompt.ask('SMTP port:') do |q| + q.required true + q.default 587 + q.convert :int + end + + env['SMTP_LOGIN'] = prompt.ask('SMTP username:') do |q| + q.modify :strip + end + + env['SMTP_PASSWORD'] = prompt.ask('SMTP password:') do |q| + q.echo false + end + + env['SMTP_FROM_ADDRESS'] = prompt.ask('E-mail address to send e-mails "from":') do |q| + q.required true + q.default "Mastodon " + q.modify :strip + end + + break unless prompt.yes?('Send a test e-mail with this configuration right now?') + + send_to = prompt.ask('Send test e-mail to:', required: true) + + begin + ActionMailer::Base.smtp_settings = { + :port => env['SMTP_PORT'], + :address => env['SMTP_SERVER'], + :user_name => env['SMTP_LOGIN'].presence, + :password => env['SMTP_PASSWORD'].presence, + :domain => env['LOCAL_DOMAIN'], + :authentication => :plain, + :enable_starttls_auto => true, + } + + ActionMailer::Base.default_options = { + from: env['SMTP_FROM_ADDRESS'], + } + + mail = ActionMailer::Base.new.mail to: send_to, subject: 'Test', body: 'Mastodon SMTP configuration works!' + mail.deliver + rescue StandardError => e + prompt.error 'E-mail could not be sent with this configuration, try again.' + prompt.error e.message + break unless prompt.yes?('Try again?') + end + end + + prompt.say "\n" + prompt.say 'This configuration will be written to .env.production' + + if prompt.yes?('Save configuration?') + cmd = TTY::Command.new(printer: :quiet) + + File.write(Rails.root.join('.env.production'), "# Generated with mastodon:setup on #{Time.now.utc}\n\n" + env.each_pair.map { |key, value| "#{key}=#{value}" }.join("\n") + "\n") + + prompt.say "\n" + prompt.say 'Now that configuration is saved, the database schema must be loaded.' + prompt.warn 'If the database already exists, this will erase its contents.' + + if prompt.yes?('Prepare the database now?') + prompt.say 'Running `RAILS_ENV=production rails db:setup` ...' + prompt.say "\n" + + if cmd.run!({ RAILS_ENV: 'production' }, :rails, 'db:setup').failure? + prompt.say "\n" + prompt.error 'That failed! Perhaps your configuration is not right' + else + prompt.say "\n" + prompt.ok 'Done!' + end + end + + prompt.say "\n" + prompt.say 'The final step is compiling CSS/JS assets.' + prompt.say 'This may take a while and consume a lot of RAM.' + + if prompt.yes?('Compile the assets now?') + prompt.say 'Running `RAILS_ENV=production rails assets:precompile` ...' + prompt.say "\n" + + if cmd.run!({ RAILS_ENV: 'production' }, :rails, 'assets:precompile').failure? + prompt.say "\n" + prompt.error 'That failed! Maybe you need swap space?' + else + prompt.say "\n" + prompt.say 'Done!' + end + end + + prompt.say "\n" + prompt.ok 'All done! You can now power on the Mastodon server 🐘' + prompt.say "\n" + + if db_connection_works && prompt.yes?('Do you want to create an admin user straight away?') + env.each_pair do |key, value| + ENV[key] = value.to_s + end + + require_relative '../../config/environment' + disable_log_stdout! + + username = prompt.ask('Username:') do |q| + q.required true + q.default 'admin' + q.validate(/\A[a-z0-9_]+\z/i) + q.modify :strip + end + + email = prompt.ask('E-mail:') do |q| + q.required true + q.modify :strip + end + + password = SecureRandom.hex(16) + + user = User.new(admin: true, email: email, password: password, confirmed_at: Time.now.utc, account_attributes: { username: username }) + user.save(validate: false) + + prompt.ok "You can login with the password: #{password}" + prompt.warn 'You can change your password once you login.' + end + else + prompt.warn 'Nothing saved. Bye!' + end + rescue TTY::Reader::InputInterrupt + prompt.ok 'Aborting. Bye!' + end + end + desc 'Execute daily tasks (deprecated)' task :daily do # No-op @@ -67,32 +423,40 @@ namespace :mastodon do desc 'Add a user by providing their email, username and initial password.' \ 'The user will receive a confirmation email, then they must reset their password before logging in.' task add_user: :environment do - print 'Enter email: ' - email = STDIN.gets.chomp + disable_log_stdout! - print 'Enter username: ' - username = STDIN.gets.chomp + prompt = TTY::Prompt.new - print 'Create user and send them confirmation mail [y/N]: ' - confirm = STDIN.gets.chomp - puts - - if confirm.casecmp('y').zero? - password = SecureRandom.hex - user = User.new(email: email, password: password, account_attributes: { username: username }) - if user.save - puts 'User added and confirmation mail sent to user\'s email address.' - puts "Here is the random password generated for the user: #{password}" - else - puts 'Following errors occured while creating new user:' - user.errors.each do |key, val| - puts "#{key}: #{val}" - end + begin + email = prompt.ask('E-mail:', required: true) do |q| + q.modify :strip end - else - puts 'Aborted by user.' + + username = prompt.ask('Username:', required: true) do |q| + q.modify :strip + end + + role = prompt.select('Role:', %w(user moderator admin)) + + if prompt.yes?('Proceed to create the user?') + user = User.new(email: email, password: SecureRandom.hex, admin: role == 'admin', moderator: role == 'moderator', account_attributes: { username: username }) + + if user.save + prompt.ok 'User created and confirmation mail sent to the user\'s email address.' + prompt.ok "Here is the random password generated for the user: #{password}" + else + prompt.warn 'User was not created because of the following errors:' + + user.errors.each do |key, val| + prompt.error "#{key}: #{val}" + end + end + else + prompt.ok 'Aborting. Bye!' + end + rescue TTY::Reader::InputInterrupt + prompt.ok 'Aborting. Bye!' end - puts end namespace :media do