From 7dda73c55afe5db19c8a8467230388c3bff90943 Mon Sep 17 00:00:00 2001 From: Andrew Novoselac Date: Mon, 29 Jan 2024 16:13:02 -0500 Subject: [PATCH] Generate a .devcontainer folder and its contents when creating a new app. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-authored-by: Rafael Mendonça França wip --- .../lib/action_dispatch/system_test_case.rb | 2 +- railties/CHANGELOG.md | 20 +++ railties/lib/rails/generators/app_base.rb | 7 + railties/lib/rails/generators/database.rb | 8 + .../generators/rails/app/app_generator.rb | 43 ++++++ .../app/templates/.devcontainer/Dockerfile.tt | 14 ++ .../.devcontainer/devcontainer.json.tt | 34 +++++ .../.devcontainer/docker-compose.yml.tt | 78 ++++++++++ .../templates/config/databases/mysql.yml.tt | 2 +- .../config/databases/postgresql.yml.tt | 7 + .../templates/config/databases/trilogy.yml.tt | 2 +- .../test/application_system_test_case.rb.tt | 13 ++ .../db/system/change/change_generator.rb | 3 + .../test/generators/app_generator_test.rb | 142 ++++++++++++++++++ 14 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 railties/lib/rails/generators/rails/app/templates/.devcontainer/Dockerfile.tt create mode 100644 railties/lib/rails/generators/rails/app/templates/.devcontainer/devcontainer.json.tt create mode 100644 railties/lib/rails/generators/rails/app/templates/.devcontainer/docker-compose.yml.tt diff --git a/actionpack/lib/action_dispatch/system_test_case.rb b/actionpack/lib/action_dispatch/system_test_case.rb index 3e969abcb94f3..d19dadb90cc22 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 54f44fb57ab38..c9cf7928acd3b 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,23 @@ +* 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. + + Additionally, a `--skip-devcontainer` option has been added to the `db:system:change` command. It is set to `true` + by default so devcontainer config will not be added to the `database.yml` for existing apps using the command. But + the user can opt in to the devcontainer config by setting the flag to false: `db:system:change --to=postgresql --skip-devcontainer=false` + + *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 9116579ad0a5c..d7f044c7bdcfe 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/database.rb b/railties/lib/rails/generators/database.rb index 2236092317cf0..39b6f38a6dcbd 100644 --- a/railties/lib/rails/generators/database.rb +++ b/railties/lib/rails/generators/database.rb @@ -90,6 +90,14 @@ def mysql_socket "/opt/lampp/var/mysql/mysql.sock" # xampp for linux ].find { |f| File.exist?(f) } unless Gem.win_platform? end + + def mysql_database_host + if options[:skip_devcontainer] + "localhost" + else + "<%= ENV.fetch(\"RAILS_DATABASE_HOST\") { \"localhost\" } %>" + end + end end end end diff --git a/railties/lib/rails/generators/rails/app/app_generator.rb b/railties/lib/rails/generators/rails/app/app_generator.rb index 51f08464ba55a..4ba2e226cf0a7 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,36 @@ 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 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 0000000000000..e8368c6ab30c6 --- /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 0000000000000..6aae049fa72d9 --- /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 0000000000000..a785fb1557c59 --- /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 c0b48fae02916..ee69408107113 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: 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 3bb5a491dbb84..c8db390b3c485 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,6 +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 options[: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 a22ccca2acbfe..b0cd1e73f9cc1 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: 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 cee29fd2143ea..b5e2a0b0a98cf 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/lib/rails/generators/rails/db/system/change/change_generator.rb b/railties/lib/rails/generators/rails/db/system/change/change_generator.rb index 5bfb56bef15c7..bf7b55e7a5e4d 100644 --- a/railties/lib/rails/generators/rails/db/system/change/change_generator.rb +++ b/railties/lib/rails/generators/rails/db/system/change/change_generator.rb @@ -13,6 +13,9 @@ class ChangeGenerator < Base # :nodoc: class_option :to, required: true, desc: "The database system to switch to." + class_option :skip_devcontainer, type: :boolean, default: true, + desc: "Skip devcontainer config in database.yml" + def self.default_generator_root path = File.expand_path(File.join(base_name, "app"), base_root) path if File.exist?(path) diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index dd7742d5d0fbd..3c4d35e458200 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,145 @@ 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 + assert_file("config/database.yml") do |content| + assert_match(/host: <%= ENV\["RAILS_DATABASE_HOST"\] %>/, 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 + assert_file("config/database.yml") do |content| + assert_match(/host: <%= ENV.fetch\("RAILS_DATABASE_HOST"\) \{ "localhost" } %>/, 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 + assert_file("config/database.yml") do |content| + assert_match(/host: <%= ENV.fetch\("RAILS_DATABASE_HOST"\) \{ "localhost" } %>/, 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|