diff --git a/actionpack/lib/action_dispatch/system_test_case.rb b/actionpack/lib/action_dispatch/system_test_case.rb index 3e969abcb94f..d19dadb90cc2 100644 --- a/actionpack/lib/action_dispatch/system_test_case.rb +++ b/actionpack/lib/action_dispatch/system_test_case.rb @@ -159,7 +159,7 @@ def self.driven_by(driver, using: :chrome, screen_size: [1400, 1400], options: { self.driver = SystemTesting::Driver.new(driver, **driver_options, &capabilities) end - # Configuration for the System Test application server + # Configuration for the System Test application server. # # By default this is localhost. This method allows the host and port to be specified manually. def self.served_by(host:, port:) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 54f44fb57ab3..1fbf1bae7782 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,19 @@ +* Generate a .devcontainer folder and its contents when creating a new app. + + The .devcontainer folder includes everything needed to boot the app and do development in a remote container. + + The container setup includes: + - A redis container for Sidekiq and Action Cable + - A database (SQLite, Postgres, MySQL or MariaDB) + - A Headless chrome container for system tests + - Active Storage configured to use the local disk and with preview features working + + If any of these options are skipped in the app setup they will not be included in the container configuration. + + These files can be skipped using the `--skip-devcontainer` option. + + *Andrew Novoselac & Rafael Mendonça França* + * Introduce `SystemTestCase#servered_by` for configuring the System Test application server By default this is localhost. This method allows the host and port to be specified manually. diff --git a/railties/lib/rails/generators/app_base.rb b/railties/lib/rails/generators/app_base.rb index 9116579ad0a5..d7f044c7bdcf 100644 --- a/railties/lib/rails/generators/app_base.rb +++ b/railties/lib/rails/generators/app_base.rb @@ -109,6 +109,9 @@ def self.add_shared_options_for(name) class_option :skip_ci, type: :boolean, default: nil, desc: "Skip GitHub CI files" + class_option :skip_devcontainer, type: :boolean, default: nil, + desc: "Skip devcontainer files" + class_option :dev, type: :boolean, default: nil, desc: "Set up the #{name} with Gemfile pointing to your Rails checkout" @@ -400,6 +403,10 @@ def skip_ci? options[:skip_ci] end + def skip_devcontainer? + options[:skip_devcontainer] + end + class GemfileEntry < Struct.new(:name, :version, :comment, :options, :commented_out) def initialize(name, version, comment, options = {}, commented_out = false) super diff --git a/railties/lib/rails/generators/rails/app/app_generator.rb b/railties/lib/rails/generators/rails/app/app_generator.rb index 51f08464ba55..33e9a29ff251 100644 --- a/railties/lib/rails/generators/rails/app/app_generator.rb +++ b/railties/lib/rails/generators/rails/app/app_generator.rb @@ -266,6 +266,14 @@ def vendor def config_target_version @config_target_version || Rails::VERSION::STRING.to_f end + + def devcontainer + empty_directory ".devcontainer" + + template ".devcontainer/devcontainer.json" + template ".devcontainer/Dockerfile" + template ".devcontainer/docker-compose.yml" + end end module Generators @@ -455,6 +463,11 @@ def create_storage_files build(:storage) end + def create_devcontainer_files + return if skip_devcontainer? || options[:dummy_app] + build(:devcontainer) + end + def delete_app_assets_if_api_option if options[:api] remove_dir "app/assets" @@ -592,6 +605,44 @@ def after_bundle(&block) # :doc: def get_builder_class defined?(::AppBuilder) ? ::AppBuilder : Rails::AppBuilder end + + def devcontainer_dependencies + return @devcontainer_dependencies if @devcontainer_dependencies + + @devcontainer_dependencies = [] + + @devcontainer_dependencies << "selenium" if depends_on_system_test? + @devcontainer_dependencies << "redis" unless options.skip_action_cable? && options.skip_active_job? + @devcontainer_dependencies << "postgres" if options.database == "postgresql" + @devcontainer_dependencies << "mysql" if options.database == "mysql" + @devcontainer_dependencies << "mariadb" if options.database == "trilogy" + + @devcontainer_dependencies + end + + def devcontainer_variables + return @devcontainer_variables if @devcontainer_variables + + @devcontainer_variables = {} + + @devcontainer_variables["CAPYBARA_SERVER_PORT"] = "45678" if depends_on_system_test? + @devcontainer_variables["SELENIUM_HOST"] = "selenium" if depends_on_system_test? + @devcontainer_variables["JOBS_REDIS_URL"] = "redis://redis:6379/1" unless options.skip_active_job? + @devcontainer_variables["CABLE_REDIS_URL"] = "redis://redis:6379/1" unless options.skip_action_cable? + @devcontainer_variables["RAILS_DATABASE_HOST"] = "postgres" if options.database == "postgresql" + @devcontainer_variables["RAILS_DATABASE_HOST"] = "mysql" if options.database == "mysql" + @devcontainer_variables["RAILS_DATABASE_HOST"] = "mariadb" if options.database == "trilogy" + + @devcontainer_variables + end + + def mysql_database_host + if skip_devcontainer? + "localhost" + else + "<%= ENV.fetch(\"RAILS_DATABASE_HOST\") { \"localhost\" } %>" + end + end end # This class handles preparation of the arguments before the AppGenerator is diff --git a/railties/lib/rails/generators/rails/app/templates/.devcontainer/Dockerfile.tt b/railties/lib/rails/generators/rails/app/templates/.devcontainer/Dockerfile.tt new file mode 100644 index 000000000000..e8368c6ab30c --- /dev/null +++ b/railties/lib/rails/generators/rails/app/templates/.devcontainer/Dockerfile.tt @@ -0,0 +1,14 @@ +FROM mcr.microsoft.com/devcontainers/ruby:1-3-bookworm + +<%- unless options.skip_active_storage -%> +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + libvips \ + # For video thumbnails + ffmpeg \ + # For pdf thumbnails. If you want to use mupdf instead of poppler, + # you can install the following packages instead: + # mupdf mupdf-tools + poppler-utils +<%- end -%> \ No newline at end of file diff --git a/railties/lib/rails/generators/rails/app/templates/.devcontainer/devcontainer.json.tt b/railties/lib/rails/generators/rails/app/templates/.devcontainer/devcontainer.json.tt new file mode 100644 index 000000000000..6aae049fa72d --- /dev/null +++ b/railties/lib/rails/generators/rails/app/templates/.devcontainer/devcontainer.json.tt @@ -0,0 +1,34 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/ruby +{ + "name": "<%= app_name %>", + "dockerComposeFile": "docker-compose.yml", + "service": "rails-app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + +<%- if !devcontainer_variables.empty? -%> + "containerEnv": { + <%= devcontainer_variables.map { |key, value| "\"#{key}\": \"#{value}\"" }.join(",\n ") %> + }, +<%- end -%> + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root", + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "bin/setup" +} diff --git a/railties/lib/rails/generators/rails/app/templates/.devcontainer/docker-compose.yml.tt b/railties/lib/rails/generators/rails/app/templates/.devcontainer/docker-compose.yml.tt new file mode 100644 index 000000000000..a785fb1557c5 --- /dev/null +++ b/railties/lib/rails/generators/rails/app/templates/.devcontainer/docker-compose.yml.tt @@ -0,0 +1,78 @@ +services: + rails-app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + + volumes: + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + networks: + - default + + # Uncomment the next line to use a non-root user for all processes. + # user: vscode + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + ports: + - 45678:45678 +<%- if !devcontainer_dependencies.empty? -%> + depends_on: +<%- devcontainer_dependencies.each do |dependency| -%> + - <%= dependency %> +<%- end -%> +<%- end -%> + +<%- if depends_on_system_test? -%> + selenium: + image: seleniarm/standalone-chromium + restart: unless-stopped + networks: + - default +<%- end -%> + +<%- unless options.skip_active_job? && options.skip_action_cable? -%> + redis: + image: redis:7.2 + restart: unless-stopped + networks: + - default + ports: + - 6379:6379 +<%- end -%> + +<%- if options.database == "postgresql" -%> + postgres: + image: postgres:16.1 + restart: unless-stopped + networks: + - default + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres +<%- end -%> + +<%- if options.database == "mysql" -%> + mysql: + image: mysql/mysql-server:8.0 + restart: unless-stopped + environment: + MYSQL_ALLOW_EMPTY_PASSWORD: true + MYSQL_ROOT_HOST: "%" + networks: + - default +<%- end -%> + +<%- if options.database == "trilogy" -%> + mariadb: + image: mariadb:10.5 + restart: unless-stopped + networks: + - default + environment: + MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: true +<%- end -%> \ No newline at end of file diff --git a/railties/lib/rails/generators/rails/app/templates/config/databases/mysql.yml.tt b/railties/lib/rails/generators/rails/app/templates/config/databases/mysql.yml.tt index e3c5f28566ab..ee6940810711 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/databases/mysql.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/databases/mysql.yml.tt @@ -18,7 +18,7 @@ default: &default <% if mysql_socket -%> socket: <%= mysql_socket %> <% else -%> - host: <%%= ENV.fetch("RAILS_DATABASE_HOST") { "localhost" } %> + host: <%= mysql_database_host %> <% end -%> development: diff --git a/railties/lib/rails/generators/rails/app/templates/config/databases/postgresql.yml.tt b/railties/lib/rails/generators/rails/app/templates/config/databases/postgresql.yml.tt index d58a81e7f5b0..b4bbe90690d1 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/databases/postgresql.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/databases/postgresql.yml.tt @@ -18,11 +18,13 @@ default: &default # For details on connection pooling, see Rails configuration guide # https://guides.rubyonrails.org/configuring.html#database-pooling pool: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> +<% unless skip_devcontainer? -%> <%% if ENV["RAILS_DATABASE_HOST"] %> host: <%%= ENV["RAILS_DATABASE_HOST"] %> username: postgres password: postgres <%% end %> +<% end %> development: <<: *default diff --git a/railties/lib/rails/generators/rails/app/templates/config/databases/trilogy.yml.tt b/railties/lib/rails/generators/rails/app/templates/config/databases/trilogy.yml.tt index 33e790ae42c2..b0cd1e73f9cc 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/databases/trilogy.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/databases/trilogy.yml.tt @@ -18,7 +18,7 @@ default: &default <% if mysql_socket -%> socket: <%= mysql_socket %> <% else -%> - host: <%%= ENV.fetch("RAILS_DATABASE_HOST") { "localhost" } %> + host: <%= mysql_database_host %> <% end -%> development: diff --git a/railties/lib/rails/generators/rails/app/templates/test/application_system_test_case.rb.tt b/railties/lib/rails/generators/rails/app/templates/test/application_system_test_case.rb.tt index cee29fd2143e..b5e2a0b0a98c 100644 --- a/railties/lib/rails/generators/rails/app/templates/test/application_system_test_case.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/test/application_system_test_case.rb.tt @@ -1,5 +1,18 @@ require "test_helper" class ApplicationSystemTestCase < ActionDispatch::SystemTestCase +<% if skip_devcontainer? -%> driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] +<% else -%> + if ENV["CAPYBARA_SERVER_PORT"] + served_by host: "rails-app", port: ENV["CAPYBARA_SERVER_PORT"] + + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ], options: { + browser: :remote, + url: "http://#{ENV["SELENIUM_HOST"]}:4444", + } + else + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] + end +<% end -%> end diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index dd7742d5d0fb..fe4efb8b0191 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -5,6 +5,9 @@ require "generators/shared_generator_tests" DEFAULT_APP_FILES = %w( + .devcontainer/Dockerfile + .devcontainer/devcontainer.json + .devcontainer/docker-compose.yml .dockerignore .git .gitattributes @@ -1229,6 +1232,136 @@ def test_name_option assert_file "config/application.rb", /^module MyApp$/ end + def test_devcontainer + run_generator [destination_root, "--name=my-app"] + + assert_file(".devcontainer/devcontainer.json") do |content| + assert_match(/"name": "my_app"/, content) + assert_match(/"JOBS_REDIS_URL": "redis:\/\/redis:6379\/1"/, content) + assert_match(/"CABLE_REDIS_URL": "redis:\/\/redis:6379\/1"/, content) + assert_match(/"CAPYBARA_SERVER_PORT": "45678"/, content) + assert_match(/"SELENIUM_HOST": "selenium"/, content) + end + assert_file(".devcontainer/Dockerfile") do |content| + assert_match(/libvips/, content) + assert_match(/ffmpeg/, content) + assert_match(/poppler-utils/, content) + end + assert_file(".devcontainer/docker-compose.yml") do |content| + assert_match(/depends_on:/, content) + assert_match(/- selenium/, content) + assert_match(/- redis/, content) + assert_no_match(/- maria_db/, content) + assert_no_match(/- postgres/, content) + assert_no_match(/- mysql/, content) + + assert_match(/selenium:/, content) + assert_match(/redis:/, content) + assert_no_match(/maria_db:/, content) + assert_no_match(/postgres:/, content) + assert_no_match(/mysql:/, content) + end + end + + def test_devcontainer_no_redis_skipping_action_cable_and_active_job + run_generator [ destination_root, "--skip_action_cable", "--skip_active_job" ] + + assert_file(".devcontainer/docker-compose.yml") do |content| + assert_no_match(/- redis/, content) + assert_no_match(/redis:/, content) + end + end + + def test_devcontainer_no_jobs_redis_url_when_skipping_active_job + run_generator [ destination_root, "--skip_active_job" ] + + assert_file(".devcontainer/devcontainer.json") do |content| + assert_no_match(/JOBS_REDIS_URL/, content) + end + end + + def test_devcontainer_no_cable_redis_url_when_skipping_action_cable + run_generator [ destination_root, "--skip_action_cable" ] + + assert_file(".devcontainer/devcontainer.json") do |content| + assert_no_match(/CABLE_REDIS_URL/, content) + end + end + + def test_devonctainer_postgresql + run_generator [ destination_root, "-d", "postgresql" ] + + assert_file(".devcontainer/docker-compose.yml") do |content| + assert_match(/- postgres/, content) + assert_match(/postgres:/, content) + end + assert_file(".devcontainer/devcontainer.json") do |content| + assert_match(/"RAILS_DATABASE_HOST": "postgres"/, content) + end + end + + def test_devonctainer_mysql + run_generator [ destination_root, "-d", "mysql" ] + + assert_file(".devcontainer/docker-compose.yml") do |content| + assert_match(/- mysql/, content) + assert_match(/mysql:/, content) + end + assert_file(".devcontainer/devcontainer.json") do |content| + assert_match(/"RAILS_DATABASE_HOST": "mysql"/, content) + end + end + + def test_devonctainer_mariadb + run_generator [ destination_root, "-d", "trilogy" ] + + assert_file(".devcontainer/docker-compose.yml") do |content| + assert_match(/- mariadb/, content) + assert_match(/mariadb:/, content) + end + assert_file(".devcontainer/devcontainer.json") do |content| + assert_match(/"RAILS_DATABASE_HOST": "mariadb"/, content) + end + end + + def test_devcontainer_no_selenium_when_skipping_system_test + run_generator [ destination_root, "--skip_system_test" ] + + assert_file(".devcontainer/docker-compose.yml") do |content| + assert_no_match(/- selenium/, content) + assert_no_match(/selenium:/, content) + end + assert_file(".devcontainer/devcontainer.json") do |content| + assert_no_match(/CAPYBARA_SERVER_PORT/, content) + end + end + + def test_devcontainer_no_Dockerfile_packages_when_skipping_active_storage + run_generator [ destination_root, "--skip_active_storage" ] + + assert_file(".devcontainer/Dockerfile") do |content| + assert_no_match(/libvips/, content) + assert_no_match(/ffmpeg/, content) + assert_no_match(/poppler-utils/, content) + end + end + + def test_devcontainer_no_depends_on_when_no_dependencies + run_generator [ destination_root, "--minimal" ] + + assert_file(".devcontainer/docker-compose.yml") do |content| + assert_no_match(/depends_on:/, content) + end + end + + def test_skip_devcontainer + run_generator [ destination_root, "--skip_devcontainer" ] + + assert_no_file(".devcontainer/devcontainer.json") + assert_no_file(".devcontainer/Dockerfile") + assert_no_file(".devcontainer/docker-compose.yml") + end + private def assert_node_files assert_file ".node-version" do |content|