diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..d1dd66a33a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.bundle +.env* +tmp +frontend/node_modules +# travis +vendor/bundle diff --git a/.travis.yml b/.travis.yml index e8baa19bac..62bf826502 100644 --- a/.travis.yml +++ b/.travis.yml @@ -39,7 +39,7 @@ cache: - frontend/node_modules - frontend/bower_components -bundler_args: --without development production +bundler_args: --without development production docker branches: only: diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..7fc4314a85 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +FROM ruby:2.1 + +ENV NODE_VERSION="0.12.7" +ENV BUNDLER_VERSION="1.10.6" + +# install node + npm +RUN curl https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz | tar xzf - -C /usr/local --strip-components=1 + +# Using /home/app since npm cache and other stuff will be put there when running npm install +# We don't want to pollute any locally-mounted directory +RUN useradd -d /home/app -m app +RUN mkdir -p /usr/src/app +RUN chown -R app /usr/src/app /usr/local/bundle +RUN gem install bundler --version "${BUNDLER_VERSION}" + +WORKDIR /usr/src/app + +# https registry breaks so often it's no longer funny +RUN echo "registry = 'http://registry.npmjs.org/'" >> /usr/local/etc/npmrc +# moar logs +RUN echo "loglevel=info" >> /usr/local/etc/npmrc + +COPY Gemfile ./Gemfile +COPY Gemfile.* ./ +RUN chown -R app:app /usr/src/app + +USER app +RUN bundle install --jobs 8 --retry 3 + +USER root +# Then, npm install node modules +COPY package.json /tmp/npm/package.json +COPY frontend/*.json /tmp/npm/frontend/ +RUN chown -R app:app /tmp/npm + +USER app +RUN cd /tmp/npm && RAILS_ENV=production npm install +RUN mv /tmp/npm/frontend /usr/src/app/ + +# Finally, copy over the whole thing +USER root +COPY . /usr/src/app +RUN cp docker/Procfile . +RUN chown -R app:app /usr/src/app + +USER app +RUN DATABASE_URL=sqlite3:///tmp/db.sqlite3 SECRET_TOKEN=foobar RAILS_ENV=production bundle exec rake assets:precompile + +CMD ["./docker/web"] diff --git a/Gemfile b/Gemfile index bd2d9c8014..495cf6c67a 100644 --- a/Gemfile +++ b/Gemfile @@ -233,6 +233,19 @@ group :opf_plugins do gem 'openproject-translations', git:'https://github.com/opf/openproject-translations.git', branch: 'release/5.0' end +# TODO: Make this group :optional when bundler v10.x +# is matured enough that we can use this everywhere +# http://bundler.io/blog/2015/06/24/version-1-10-released.html +group :docker do + gem 'passenger' + + # Used to easily precompile assets + gem 'sqlite3', require: false + gem 'rails_12factor', require: !!ENV['HEROKU'] + gem 'health_check', require: !!ENV['HEROKU'] + gem 'newrelic_rpm', require: !!ENV['HEROKU'] +end + # Load Gemfile.local, Gemfile.plugins and plugins' Gemfiles Dir.glob File.expand_path('../{Gemfile.local,Gemfile.plugins,lib/plugins/*/Gemfile}', __FILE__) do |file| next unless File.readable?(file) diff --git a/Gemfile.lock b/Gemfile.lock index e5b625e71b..4a9623c5e2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -278,6 +278,8 @@ GEM virtus (>= 1.0.0) gravatar_image_tag (1.2.0) hashie (3.4.1) + health_check (1.5.1) + rails (>= 2.3.0) hike (1.2.3) htmldiff (0.0.1) http-cookie (1.0.2) @@ -315,6 +317,7 @@ GEM net-ssh (>= 2.6.5) net-ssh (2.9.2) netrc (0.11.0) + newrelic_rpm (3.14.1.311) nokogiri (1.6.6.4) mini_portile (~> 0.6.0) non-stupid-digest-assets (1.0.4) @@ -327,6 +330,9 @@ GEM parallel parser (2.2.2.5) ast (>= 1.1, < 3.0) + passenger (5.0.22) + rack + rake (>= 0.8.1) pg (0.18.3) poltergeist (1.7.0) capybara (~> 2.1) @@ -389,8 +395,13 @@ GEM loofah (~> 2.0) rails-observers (0.1.2) activemodel (~> 4.0) + rails_12factor (0.0.3) + rails_serve_static_assets + rails_stdout_logging rails_autolink (1.1.6) rails (> 3.1) + rails_serve_static_assets (0.0.4) + rails_stdout_logging (0.0.4) railties (4.2.4) actionpack (= 4.2.4) activesupport (= 4.2.4) @@ -490,6 +501,7 @@ GEM actionpack (>= 3.0) activesupport (>= 3.0) sprockets (>= 2.8, < 4.0) + sqlite3 (1.3.11) structured_warnings (0.2.0) syck (1.0.5) sys-filesystem (1.1.4) @@ -576,6 +588,7 @@ DEPENDENCIES gon (~> 4.0) grape (~> 0.10.1) gravatar_image_tag (~> 1.2.0) + health_check htmldiff jruby-openssl json_spec @@ -584,12 +597,14 @@ DEPENDENCIES multi_json (~> 1.11.0) mysql2 (~> 0.3.20) net-ldap (~> 0.8.0) + newrelic_rpm nokogiri (~> 1.6.6) non-stupid-digest-assets oj (~> 2.11.4) omniauth openproject-translations! parallel_tests (~> 2.1.2) + passenger pg (~> 0.18.3) poltergeist prototype-rails! @@ -607,6 +622,7 @@ DEPENDENCIES rack_session_access rails (= 4.2.4) rails-observers + rails_12factor rails_autolink (~> 1.1.6) rb-readline (~> 0.5.1) rdoc (>= 2.4.2) @@ -631,6 +647,7 @@ DEPENDENCIES shoulda-matchers (~> 2.8) simplecov (= 0.8.0.pre) sprockets (~> 2.12.3) + sqlite3 svg-graph! syck (~> 1.0.5) sys-filesystem (~> 1.1.4) diff --git a/doc/operation_guides/manual/installation-guide.md b/doc/operation_guides/manual/installation-guide.md index 96eb05d005..aa7f058515 100644 --- a/doc/operation_guides/manual/installation-guide.md +++ b/doc/operation_guides/manual/installation-guide.md @@ -146,7 +146,7 @@ with OpenProject. For more information, see https://github.com/opf/openproject-c [openproject@host] git clone https://github.com/opf/openproject-ce.git --branch stable/5 --depth 1 [openproject@host] cd openproject-ce [openproject@host] gem install bundler -[openproject@host] bundle install --deployment --without postgres sqlite development test therubyracer +[openproject@host] bundle install --deployment --without postgres sqlite development test therubyracer docker [openproject@host] npm install ``` diff --git a/docker/Procfile b/docker/Procfile new file mode 100644 index 0000000000..98bd8e1fa7 --- /dev/null +++ b/docker/Procfile @@ -0,0 +1,3 @@ +web: ./docker/web +worker: ./docker/worker +cron: ./docker/cron diff --git a/docker/cron b/docker/cron new file mode 100755 index 0000000000..b890a0ea8c --- /dev/null +++ b/docker/cron @@ -0,0 +1,26 @@ +#!/bin/bash + +set -e + +IMAP_SSL=${IMAP_SSL:="true"} +IMAP_PORT=${IMAP_PORT:="993"} +IMAP_ENABLED="${IMAP_ENABLED:="true"}" +IMAP_CHECK_INTERVAL="${IMAP_CHECK_INTERVAL:=600}" + +while true; do + if [ "$IMAP_ENABLED" = "true" ]; then + echo "[cron] Checking for new emails from IMAP" + bundle exec rake redmine:email:receive_imap \ + host="${IMAP_HOST}" \ + username="${IMAP_USERNAME}" \ + password="${IMAP_PASSWORD}" \ + ssl=${IMAP_SSL} \ + port=${IMAP_PORT} \ + allow_override="${IMAP_ALLOW_OVERRIDE}" || true + else + echo "[cron] IMAP email checking is disabled" + fi + + echo "[cron] Rescheduling in ${IMAP_CHECK_INTERVAL}s" + sleep ${IMAP_CHECK_INTERVAL}s +done diff --git a/docker/nginx.conf.erb b/docker/nginx.conf.erb new file mode 100644 index 0000000000..edd18fb069 --- /dev/null +++ b/docker/nginx.conf.erb @@ -0,0 +1,211 @@ +############################################################## +# Phusion Passenger Standalone uses a template file to +# generate an Nginx configuration file. The original template +# file can be found by running the following command: +# +# ls $(passenger-config about resourcesdir)/templates/standalone/config.erb +# +# You can create a copy of this template file and customize it +# to your liking. Just make sure you tell Phusion Passenger Standalone +# to use your template file by passing the --nginx-config-template +# parameter. +# +# *** NOTE *** +# If you customize the template file, make sure you keep an eye +# on the original template file and merge any changes. +# New Phusion Passenger features may require changes to the template +# file. +############################################################## + + +master_process on; +worker_processes 1; +daemon on; +error_log '<%= @options[:log_file] %>' <% if @options[:log_level] >= LVL_DEBUG %>info<% end %>; +pid '<%= @options[:pid_file] %>'; + +<% if Process.euid == 0 %> + <% if @options[:user] %> + <%# Run workers as the given user. The master process will always run as root and will be able to bind to any port. %> + user <%= @options[:user] %> <%= default_group_for(@options[:user]) %>; + <% else %> + <%# Prevent running Nginx workers as nobody. %> + user <%= current_user %> <%= default_group_for(current_user) %>; + <% end %> +<% end %> + +events { + worker_connections 1024; +} + +http { + log_format debug '[$time_local] $msec "$request" $status conn=$connection sent=$bytes_sent body_sent=$body_bytes_sent'; + include '<%= PhusionPassenger.resources_dir %>/mime.types'; + + <% if @options[:ruby] %> + passenger_ruby <%= @options[:ruby] %>; + <% else %> + passenger_ruby <%= PlatformInfo.ruby_command %>; + <% end %> + <% if @options[:nodejs] %> + passenger_nodejs <%= @options[:nodejs] %>; + <% end %> + <% if @options[:python] %> + passenger_python <%= @options[:python] %>; + <% end %> + + passenger_root '<%= PhusionPassenger.install_spec %>'; + passenger_abort_on_startup_error on; + passenger_ctl cleanup_pidfiles <%= serialize_strset("#{@working_dir}/temp_dir_toucher.pid") %>; + passenger_ctl integration_mode standalone; + passenger_ctl standalone_engine nginx; + passenger_user_switching off; + <%= nginx_option :passenger_log_level, :log_level %> + <%= nginx_option :passenger_max_pool_size, :max_pool_size %> + <%= nginx_option :passenger_min_instances, :min_instances %> + <%= nginx_option :passenger_pool_idle_time, :pool_idle_time %> + <%= nginx_option :passenger_max_preloader_idle_time, :max_preloader_idle_time %> + <%= nginx_option :passenger_turbocaching, :turbocaching %> + <% if @options[:user] %> + passenger_user <%= @options[:user] %>; + passenger_default_user <%= @options[:user] %>; + passenger_analytics_log_user <%= @options[:user] %>; + <% else %> + passenger_user <%= current_user %>; + passenger_default_user <%= current_user %>; + passenger_analytics_log_user <%= current_user %>; + <% end %> + <% if @options[:instance_registry_dir] %>passenger_instance_registry_dir '<%= @options[:instance_registry_dir] %>';<% end %> + <% if @options[:data_buffer_dir] %>passenger_data_buffer_dir '<%= @options[:data_buffer_dir] %>';<% end %> + <% if @options[:rolling_restarts] %>passenger_rolling_restarts on;<% end %> + <% if @options[:resist_deployment_errors] %>passenger_resist_deployment_errors on;<% end %> + <% if !@options[:load_shell_envvars] %>passenger_load_shell_envvars off;<% end %> + + <% if !@options[:friendly_error_pages].nil? -%> + passenger_friendly_error_pages <%= boolean_config_value(@options[:friendly_error_pages]) %>; + <% end %> + + <% if @options[:union_station_gateway_address] %> + union_station_gateway_address <%= @options[:union_station_gateway_address] %>; + <% end %> + <% if @options[:union_station_gateway_port] %> + union_station_gateway_port <%= @options[:union_station_gateway_port] %>; + <% end %> + <% if @options[:union_station_gateway_cert] %> + union_station_gateway_cert -; + <% end %> + + <% @options[:ctls].each do |ctl| %> + passenger_ctl '<%= ctl.split("=", 2)[0] %>' '<%= ctl.split("=", 2)[1] %>'; + <% end %> + + default_type application/octet-stream; + types_hash_max_size 2048; + server_names_hash_bucket_size 64; + client_max_body_size 20m; + access_log off; + keepalive_timeout 60; + underscores_in_headers on; + gzip on; + gzip_comp_level 3; + gzip_min_length 150; + gzip_proxied any; + gzip_types text/plain text/css text/json text/javascript + application/javascript application/x-javascript application/json + application/rss+xml application/vnd.ms-fontobject application/x-font-ttf + application/xml font/opentype image/svg+xml text/xml; + + <% if @app_finder.multi_mode? %> + # Default server entry for mass deployment mode. + server { + <% if @options[:ssl] %> + <% if @options[:ssl_port] %> + listen <%= nginx_listen_address %>; + listen <%= nginx_listen_address_with_ssl_port %> ssl; + <% else %> + listen <%= nginx_listen_address %> ssl; + <% end %> + <% else %> + listen <%= nginx_listen_address %>; + <% end %> + root '<%= PhusionPassenger.resources_dir %>/standalone_default_root'; + } + <% end %> + + <% for app in @apps %> + server { + <% if app[:ssl] %> + <% if app[:ssl_port] %> + listen <%= nginx_listen_address(app) %>; + listen <%= nginx_listen_address_with_ssl_port(app) %> ssl; + <% else %> + listen <%= nginx_listen_address(app) %> ssl; + <% end %> + <% else %> + listen <%= nginx_listen_address(app) %>; + <% end %> + server_name <%= app[:server_names].join(' ') %>; + <% if app[:static_files_dir] %> + root '<%= app[:static_files_dir] %>'; + <% else %> + root '<%= app[:root] %>/public'; + <% end %> + passenger_app_root '<%= app[:root] %>'; + passenger_enabled on; + passenger_app_env <%= app[:environment] %>; + passenger_spawn_method <%= app[:spawn_method] %>; + <% if app[:app_type] %>passenger_app_type <%= app[:app_type] %>;<% end %> + <% if app[:startup_file] %>passenger_startup_file <%= app[:startup_file] %>;<% end %> + <% if app[:concurrency_model] && app[:concurrency_model] != DEFAULT_CONCURRENCY_MODEL %>passenger_concurrency_model <%= app[:concurrency_model] %>;<% end %> + <% if app[:thread_count] && app[:thread_count] != DEFAULT_APP_THREAD_COUNT %>passenger_thread_count <%= app[:thread_count] %>;<% end %> + <% if app[:min_instances] %>passenger_min_instances <%= app[:min_instances] %>;<% end %> + <% if app[:restart_dir] %>passenger_restart_dir '<%= app[:restart_dir] %>';<% end %> + <% if app[:sticky_sessions] %>passenger_sticky_sessions on;<% end %> + <% if app[:sticky_sessions_cookie_name] %>passenger_sticky_sessions_cookie_name '<%= app[:sticky_sessions_cookie_name] %>';<% end %> + <% if app[:vary_turbocache_by_cookie] %>passenger_vary_turbocache_by_cookie '<%= app[:vary_turbocache_by_cookie] %>';<% end %> + <% if app[:union_station_key] %> + union_station_support on; + union_station_key <%= app[:union_station_key] %>; + <% end %> + <% if app[:ssl] %> + ssl_certificate <%= app[:ssl_certificate] %>; + ssl_certificate_key <%= app[:ssl_certificate_key] %>; + <% end %> + <% if @options[:meteor_app_settings] %> + passenger_meteor_app_settings <%= @options[:meteor_app_settings] %>; + <% end %> + + <% app[:envvars].each_pair do |name, value| %> + passenger_env_var '<%= name %>' '<%= value %>'; + <% end %> + + # Rails asset pipeline support. + location ~ "^/assets/.+-([0-9a-f]{32}|[0-9a-f]{64})\..+" { + error_page 490 = @static_asset; + error_page 491 = @dynamic_request; + recursive_error_pages on; + + if (-f $request_filename) { + return 490; + } + if (!-f $request_filename) { + return 491; + } + } + location @static_asset { + gzip_static on; + expires max; + add_header Cache-Control public; + add_header ETag ""; + } + location @dynamic_request { + passenger_enabled on; + } + + <% (ENV['NGINX_ADDITIONAL_SERVER_RULES'] || "").split(";").each do |rule| %> + <%= rule.chomp(";") %>; + <% end %> + } + passenger_pre_start <%= listen_url(app) %>; + <% end %> +} diff --git a/docker/web b/docker/web new file mode 100755 index 0000000000..538fd524f5 --- /dev/null +++ b/docker/web @@ -0,0 +1,23 @@ +#!/bin/bash -e + +BIND="${BIND:=0.0.0.0}" +PORT="${PORT:=8080}" +RAILS_ENV="${RAILS_ENV:="development"}" +MIGRATE="${MIGRATE:="false"}" +MIN_INSTANCES="${PASSENGER_MIN_INSTANCES:=1}" +MAX_INSTANCES="${PASSENGER_MAX_INSTANCES:=3}" +SPAWN_METHOD="${PASSENGER_SPAWN_METHOD:=smart}" + +if [ "$MIGRATE" = "true" ]; then + echo "Migrating database..." + bundle exec rake db:migrate +fi + +exec bundle exec passenger start \ + -p $PORT \ + -a "${BIND}" \ + --min-instances "$MIN_INSTANCES" \ + --max-pool-size "$MAX_INSTANCES" \ + --spawn-method "$SPAWN_METHOD" \ + --nginx-config-template "docker/nginx.conf.erb" \ + --max-preloader-idle-time 0 diff --git a/docker/worker b/docker/worker new file mode 100755 index 0000000000..d452d00de0 --- /dev/null +++ b/docker/worker @@ -0,0 +1,2 @@ +#!/bin/bash -e +exec bundle exec rake jobs:work