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
This commit is contained in:
Eugen Rochko 2018-02-11 18:40:57 +01:00 committed by GitHub
parent 718802a05d
commit cf36d184f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 414 additions and 22 deletions

View file

@ -76,6 +76,8 @@ gem 'simple-navigation', '~> 4.0'
gem 'simple_form', '~> 3.4' gem 'simple_form', '~> 3.4'
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'strong_migrations' gem 'strong_migrations'
gem 'tty-command'
gem 'tty-prompt'
gem 'twitter-text', '~> 1.14' gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2017' gem 'tzinfo-data', '~> 1.2017'
gem 'webpacker', '~> 3.0' gem 'webpacker', '~> 3.0'

View file

@ -173,6 +173,7 @@ GEM
faraday faraday
multi_json multi_json
encryptor (3.0.0) encryptor (3.0.0)
equatable (0.5.0)
erubi (1.7.0) erubi (1.7.0)
et-orbi (1.0.8) et-orbi (1.0.8)
tzinfo tzinfo
@ -224,6 +225,7 @@ GEM
hashie (3.5.7) hashie (3.5.7)
highline (1.7.10) highline (1.7.10)
hiredis (0.6.1) hiredis (0.6.1)
hitimes (1.2.6)
hkdf (0.3.0) hkdf (0.3.0)
htmlentities (4.3.4) htmlentities (4.3.4)
http (3.0.0) http (3.0.0)
@ -312,6 +314,7 @@ GEM
msgpack (1.1.0) msgpack (1.1.0)
multi_json (1.12.2) multi_json (1.12.2)
multipart-post (2.0.0) multipart-post (2.0.0)
necromancer (0.4.0)
net-scp (1.2.1) net-scp (1.2.1)
net-ssh (>= 2.6.5) net-ssh (>= 2.6.5)
net-ssh (4.2.0) net-ssh (4.2.0)
@ -356,6 +359,9 @@ GEM
parallel parallel
parser (2.4.0.2) parser (2.4.0.2)
ast (~> 2.3) ast (~> 2.3)
pastel (0.7.2)
equatable (~> 0.5.0)
tty-color (~> 0.4.0)
pg (0.21.0) pg (0.21.0)
pghero (1.7.0) pghero (1.7.0)
activerecord activerecord
@ -550,6 +556,23 @@ GEM
thread (0.2.2) thread (0.2.2)
thread_safe (0.3.6) thread_safe (0.3.6)
tilt (2.0.8) 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) twitter-text (1.14.7)
unf (~> 0.1.0) unf (~> 0.1.0)
tzinfo (1.2.4) tzinfo (1.2.4)
@ -579,6 +602,7 @@ GEM
websocket-driver (0.6.5) websocket-driver (0.6.5)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.3) websocket-extensions (0.1.3)
wisper (2.0.0)
xpath (2.1.0) xpath (2.1.0)
nokogiri (~> 1.3) nokogiri (~> 1.3)
@ -684,6 +708,8 @@ DEPENDENCIES
simplecov (~> 0.14) simplecov (~> 0.14)
sprockets-rails (~> 3.2) sprockets-rails (~> 3.2)
strong_migrations strong_migrations
tty-command
tty-prompt
twitter-text (~> 1.14) twitter-text (~> 1.14)
tzinfo-data (~> 1.2017) tzinfo-data (~> 1.2017)
uglifier (~> 3.2) uglifier (~> 3.2)

View file

@ -4,6 +4,362 @@ require 'optparse'
require 'colorize' require 'colorize'
namespace :mastodon do 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 <notifications@#{env['LOCAL_DOMAIN']}>"
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)' desc 'Execute daily tasks (deprecated)'
task :daily do task :daily do
# No-op # No-op
@ -67,32 +423,40 @@ namespace :mastodon do
desc 'Add a user by providing their email, username and initial password.' \ 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.' 'The user will receive a confirmation email, then they must reset their password before logging in.'
task add_user: :environment do task add_user: :environment do
print 'Enter email: ' disable_log_stdout!
email = STDIN.gets.chomp
print 'Enter username: ' prompt = TTY::Prompt.new
username = STDIN.gets.chomp
print 'Create user and send them confirmation mail [y/N]: ' begin
confirm = STDIN.gets.chomp email = prompt.ask('E-mail:', required: true) do |q|
puts q.modify :strip
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
end 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 end
puts
end end
namespace :media do namespace :media do