From 72f12f0b0283a335d588d1e32bd1e69775d2f9b6 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 Kredis, ActionCable etc. - 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 `--no-devcontainer` option. Co-authored-by: Rafael Mendonça França --- .../lib/action_dispatch/system_test_case.rb | 2 +- railties/CHANGELOG.md | 16 ++ railties/lib/rails/generators.rb | 1 + railties/lib/rails/generators/app_base.rb | 8 + railties/lib/rails/generators/database.rb | 8 + railties/lib/rails/generators/devcontainer.rb | 125 ++++++++++++ .../generators/rails/app/app_generator.rb | 13 ++ .../app/templates/.devcontainer/Dockerfile.tt | 14 ++ .../templates/.devcontainer/compose.yaml.tt | 55 ++++++ .../.devcontainer/devcontainer.json.tt | 34 ++++ .../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 | 62 ++++++ .../.devcontainer/compose-minimal.yaml | 14 ++ .../test/fixtures/.devcontainer/compose.yaml | 45 +++++ .../fixtures/.devcontainer/devcontainer.json | 34 ++++ .../test/generators/app_generator_test.rb | 182 ++++++++++++++++++ .../db_system_change_generator_test.rb | 87 +++++++++ .../test/generators/generators_test_helper.rb | 25 +++ 21 files changed, 746 insertions(+), 3 deletions(-) create mode 100644 railties/lib/rails/generators/devcontainer.rb create mode 100644 railties/lib/rails/generators/rails/app/templates/.devcontainer/Dockerfile.tt create mode 100644 railties/lib/rails/generators/rails/app/templates/.devcontainer/compose.yaml.tt create mode 100644 railties/lib/rails/generators/rails/app/templates/.devcontainer/devcontainer.json.tt create mode 100644 railties/test/fixtures/.devcontainer/compose-minimal.yaml create mode 100644 railties/test/fixtures/.devcontainer/compose.yaml create mode 100644 railties/test/fixtures/.devcontainer/devcontainer.json diff --git a/actionpack/lib/action_dispatch/system_test_case.rb b/actionpack/lib/action_dispatch/system_test_case.rb index 610cd4cf76866..c78131f16a462 100644 --- a/actionpack/lib/action_dispatch/system_test_case.rb +++ b/actionpack/lib/action_dispatch/system_test_case.rb @@ -161,7 +161,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 8fe54eafc2c3e..7ecf3338a7438 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 Kredis, ActionCable etc. + - 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#served_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.rb b/railties/lib/rails/generators.rb index fe261777227b0..16dbddc39a939 100644 --- a/railties/lib/rails/generators.rb +++ b/railties/lib/rails/generators.rb @@ -23,6 +23,7 @@ module Generators autoload :NamedBase, "rails/generators/named_base" autoload :ResourceHelpers, "rails/generators/resource_helpers" autoload :TestCase, "rails/generators/test_case" + autoload :Devcontainer, "rails/generators/devcontainer" mattr_accessor :namespace diff --git a/railties/lib/rails/generators/app_base.rb b/railties/lib/rails/generators/app_base.rb index 1e84be276ca10..5080d2380a091 100644 --- a/railties/lib/rails/generators/app_base.rb +++ b/railties/lib/rails/generators/app_base.rb @@ -13,6 +13,7 @@ module Rails module Generators class AppBase < Base # :nodoc: include Database + include Devcontainer include AppName NODE_LTS_VERSION = "18.15.0" @@ -109,6 +110,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: false, + 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 +404,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..443951adf179b 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(\"DB_HOST\") { \"localhost\" } %>" + end + end end end end diff --git a/railties/lib/rails/generators/devcontainer.rb b/railties/lib/rails/generators/devcontainer.rb new file mode 100644 index 0000000000000..ae351a73bb2f0 --- /dev/null +++ b/railties/lib/rails/generators/devcontainer.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Rails + module Generators + module Devcontainer + private + def devcontainer_dependencies + return @devcontainer_dependencies if @devcontainer_dependencies + + @devcontainer_dependencies = [] + + @devcontainer_dependencies << "selenium" if depends_on_system_test? + @devcontainer_dependencies << "redis" if devcontainer_needs_redis? + @devcontainer_dependencies << db_name_for_devcontainer if db_name_for_devcontainer + @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["REDIS_URL"] = "redis://redis:6379/1" if devcontainer_needs_redis? + @devcontainer_variables["DB_HOST"] = db_name_for_devcontainer if db_name_for_devcontainer + + @devcontainer_variables + end + + def devcontainer_volumes + return @devcontainer_volumes if @devcontainer_volumes + + @devcontainer_volumes = [] + + @devcontainer_volumes << "redis-data" if devcontainer_needs_redis? + @devcontainer_volumes << db_volume_name_for_devcontainer if db_volume_name_for_devcontainer + + @devcontainer_volumes + end + + def devcontainer_needs_redis? + !(options.skip_action_cable? && options.skip_active_job?) + end + + def db_name_for_devcontainer(database = options[:database]) + case database + when "mysql" then "mysql" + when "trilogy" then "mariadb" + when "postgresql" then "postgres" + end + end + + def db_volume_name_for_devcontainer(database = options[:database]) + case database + when "mysql" then "mysql-data" + when "trilogy" then "mariadb-data" + when "postgresql" then "postgres-data" + end + end + + def devcontainer_db_service_yaml(**options) + return unless service = db_service_for_devcontainer + + service.to_yaml(**options)[4..-1] + end + + def db_service_for_devcontainer(database = options[:database]) + case database + when "mysql" then mysql_service + when "trilogy" then mariadb_service + when "postgresql" then postgres_service + end + end + + def postgres_service + { + "postgres" => { + "image" => "postgres:16.1", + "restart" => "unless-stopped", + "networks" => ["default"], + "volumes" => ["postgres-data:/var/lib/postgresql/data"], + "environment" => { + "POSTGRES_USER" => "postgres", + "POSTGRES_PASSWORD" => "postgres" + } + } + } + end + + def mysql_service + { + "mysql" => { + "image" => "mysql/mysql-server:8.0", + "restart" => "unless-stopped", + "environment" => { + "MYSQL_ALLOW_EMPTY_PASSWORD" => true, + "MYSQL_ROOT_HOST" => "%" + }, + "volumes" => ["mysql-data:/var/lib/mysql"], + "networks" => ["default"], + } + } + end + + def mariadb_service + { + "mariadb" => { + "image" => "mariadb:10.5", + "restart" => "unless-stopped", + "networks" => ["dqefault"], + "volumes" => ["mariadb-data:/var/lib/mysql"], + "environment" => { + "MARIADB_ALLOW_EMPTY_ROOT_PASSWORD" => true, + }, + } + } + end + + def db_service_names + ["mysql", "mariadb", "postgres"] + 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..0310c7f7319a7 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/compose.yaml" + 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" 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/compose.yaml.tt b/railties/lib/rails/generators/rails/app/templates/.devcontainer/compose.yaml.tt new file mode 100644 index 0000000000000..81a486080eaf8 --- /dev/null +++ b/railties/lib/rails/generators/rails/app/templates/.devcontainer/compose.yaml.tt @@ -0,0 +1,55 @@ +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 -%> + +<%- if devcontainer_needs_redis? -%> + redis: + image: redis:7.2 + restart: unless-stopped + networks: + - default + volumes: + - redis-data:/data +<%- end -%> + + <%= devcontainer_db_service_yaml(indentation: 4) %> + +<%- if !devcontainer_volumes.empty? -%> +volumes: +<%- devcontainer_volumes.each do |volume| -%> + <%= volume %>: +<%- end -%> +<%- end -%> 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..18a9009b68b0d --- /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": "compose.yaml", + "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/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..90725ac5df42b 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["DB_HOST"] %> + host: <%%= ENV["DB_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..02a9c0a6475ee 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 @@ -8,6 +8,7 @@ module Db module System class ChangeGenerator < Base # :nodoc: include Database + include Devcontainer include AppName class_option :to, required: true, @@ -54,6 +55,14 @@ def edit_dockerfile end end + def edit_devcontainer_files + devcontainer_path = File.expand_path(".devcontainer", destination_root) + return unless File.exist?(devcontainer_path) + + edit_devcontainer_json + edit_compose_yaml + end + private def all_database_gems DATABASES.map { |database| gem_for_database(database) } @@ -88,6 +97,59 @@ def gem_entry_for(*gem_name_and_version) gem_name_and_version.map! { |segment| "\"#{segment}\"" } "gem #{gem_name_and_version.join(", ")}" end + + def edit_devcontainer_json + devcontainer_json_path = File.expand_path(".devcontainer/devcontainer.json", destination_root) + return unless File.exist?(devcontainer_json_path) + + container_env = JSON.parse(File.read(devcontainer_json_path))["containerEnv"] + db_name = db_name_for_devcontainer + + if container_env["DB_HOST"] + if db_name + container_env["DB_HOST"] = db_name + else + container_env.delete("DB_HOST") + end + else + if db_name + container_env["DB_HOST"] = db_name + end + end + + new_json = JSON.pretty_generate(container_env, indent: " ", object_nl: "\n ") + + gsub_file(".devcontainer/devcontainer.json", /("containerEnv"\s*:\s*){[^}]*}/, "\\1#{new_json}") + end + + def edit_compose_yaml + compose_yaml_path = File.expand_path(".devcontainer/compose.yaml", destination_root) + return unless File.exist?(compose_yaml_path) + + compose_config = YAML.load_file(compose_yaml_path) + + db_service_names.each do |db_service_name| + compose_config["services"].delete(db_service_name) + compose_config["volumes"]&.delete("#{db_service_name}-data") + compose_config["services"]["rails-app"]["depends_on"]&.delete(db_service_name) + end + + db_service = db_service_for_devcontainer + + if db_service + compose_config["services"].merge!(db_service) + compose_config["volumes"] = { db_volume_name_for_devcontainer => nil }.merge(compose_config["volumes"] || {}) + compose_config["services"]["rails-app"]["depends_on"] = [ + db_name_for_devcontainer, + compose_config["services"]["rails-app"]["depends_on"] + ].flatten.compact + end + + compose_config.delete("volumes") unless compose_config["volumes"]&.any? + compose_config["services"]["rails-app"].delete("depends_on") unless compose_config["services"]["rails-app"]["depends_on"]&.any? + + File.write(compose_yaml_path, compose_config.to_yaml) + end end end end diff --git a/railties/test/fixtures/.devcontainer/compose-minimal.yaml b/railties/test/fixtures/.devcontainer/compose-minimal.yaml new file mode 100644 index 0000000000000..354762b3ea1ef --- /dev/null +++ b/railties/test/fixtures/.devcontainer/compose-minimal.yaml @@ -0,0 +1,14 @@ +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 diff --git a/railties/test/fixtures/.devcontainer/compose.yaml b/railties/test/fixtures/.devcontainer/compose.yaml new file mode 100644 index 0000000000000..7ccf704b2bd77 --- /dev/null +++ b/railties/test/fixtures/.devcontainer/compose.yaml @@ -0,0 +1,45 @@ +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 + depends_on: + - selenium + - redis + + selenium: + image: seleniarm/standalone-chromium + restart: unless-stopped + networks: + - default + + redis: + image: redis:7.2 + restart: unless-stopped + networks: + - default + volumes: + - redis-data:/data + + + + +volumes: + redis-data: diff --git a/railties/test/fixtures/.devcontainer/devcontainer.json b/railties/test/fixtures/.devcontainer/devcontainer.json new file mode 100644 index 0000000000000..ea5b7aa3c31b9 --- /dev/null +++ b/railties/test/fixtures/.devcontainer/devcontainer.json @@ -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": "tmp", + "dockerComposeFile": "compose.yaml", + "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": {} + }, + + "containerEnv": { + "CAPYBARA_SERVER_PORT": "45678", + "SELENIUM_HOST": "selenium", + "REDIS_URL": "redis://redis:6379/1" + }, + + // 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/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index dd7742d5d0fbd..3d3b4dd177332 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/compose.yaml + .devcontainer/devcontainer.json .dockerignore .git .gitattributes @@ -1229,6 +1232,185 @@ 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(/"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_compose_file do |compose_config| + expected_rails_app_config = { + "build" => { + "context" => "..", + "dockerfile" => ".devcontainer/Dockerfile" + }, + "volumes" => ["../..:/workspaces:cached"], + "command" => "sleep infinity", + "networks" => ["default"], + "ports" => ["45678:45678"], + "depends_on" => ["selenium", "redis"] + } + + assert_equal expected_rails_app_config, compose_config["services"]["rails-app"] + + expected_selenium_conifg = { + "image" => "seleniarm/standalone-chromium", + "restart" => "unless-stopped", + "networks" => ["default"] + } + + assert_equal expected_selenium_conifg, compose_config["services"]["selenium"] + + expected_redis_config = { + "image" => "redis:7.2", + "restart" => "unless-stopped", + "networks" => ["default"], + "volumes" => ["redis-data:/data"] + } + + assert_equal expected_redis_config, compose_config["services"]["redis"] + assert_equal ["redis-data"], compose_config["volumes"].keys + end + end + + def test_devcontainer_no_redis_skipping_action_cable_and_active_job + run_generator [ destination_root, "--skip-action-cable", "--skip-active-job" ] + + assert_compose_file do |compose_config| + assert_not_includes compose_config["services"]["rails-app"]["depends_on"], "redis" + assert_nil compose_config["services"]["redis"] + assert_nil compose_config["volumes"] + end + end + + def test_devonctainer_postgresql + run_generator [ destination_root, "-d", "postgresql" ] + + assert_compose_file do |compose_config| + assert_includes compose_config["services"]["rails-app"]["depends_on"], "postgres" + + expected_postgres_config = { + "image" => "postgres:16.1", + "restart" => "unless-stopped", + "networks" => ["default"], + "volumes" => ["postgres-data:/var/lib/postgresql/data"], + "environment" => { + "POSTGRES_USER" => "postgres", + "POSTGRES_PASSWORD" => "postgres" + } + } + + assert_equal expected_postgres_config, compose_config["services"]["postgres"] + assert_includes compose_config["volumes"].keys, "postgres-data" + end + assert_file(".devcontainer/devcontainer.json") do |content| + assert_match(/"DB_HOST": "postgres"/, content) + end + assert_file("config/database.yml") do |content| + assert_match(/host: <%= ENV\["DB_HOST"\] %>/, content) + end + end + + def test_devonctainer_mysql + run_generator [ destination_root, "-d", "mysql" ] + + assert_compose_file do |compose_config| + assert_includes compose_config["services"]["rails-app"]["depends_on"], "mysql" + + expected_mysql_config = { + "image" => "mysql/mysql-server:8.0", + "restart" => "unless-stopped", + "environment" => { + "MYSQL_ALLOW_EMPTY_PASSWORD" => true, + "MYSQL_ROOT_HOST" => "%" + }, + "volumes" => ["mysql-data:/var/lib/mysql"], + "networks" => ["default"], + } + + assert_equal expected_mysql_config, compose_config["services"]["mysql"] + assert_includes compose_config["volumes"].keys, "mysql-data" + end + assert_file(".devcontainer/devcontainer.json") do |content| + assert_match(/"DB_HOST": "mysql"/, content) + end + assert_file("config/database.yml") do |content| + assert_match(/host: <%= ENV.fetch\("DB_HOST"\) \{ "localhost" } %>/, content) + end + end + + def test_devonctainer_mariadb + run_generator [ destination_root, "-d", "trilogy" ] + + assert_compose_file do |compose_config| + assert_includes compose_config["services"]["rails-app"]["depends_on"], "mariadb" + expected_mariadb_config = { + "image" => "mariadb:10.5", + "restart" => "unless-stopped", + "networks" => ["dqefault"], + "volumes" => ["mariadb-data:/var/lib/mysql"], + "environment" => { + "MARIADB_ALLOW_EMPTY_ROOT_PASSWORD" => true, + }, + } + + assert_equal expected_mariadb_config, compose_config["services"]["mariadb"] + assert_includes compose_config["volumes"].keys, "mariadb-data" + end + assert_file(".devcontainer/devcontainer.json") do |content| + assert_match(/"DB_HOST": "mariadb"/, content) + end + assert_file("config/database.yml") do |content| + assert_match(/host: <%= ENV.fetch\("DB_HOST"\) \{ "localhost" } %>/, content) + end + end + + def test_devcontainer_no_selenium_when_skipping_system_test + run_generator [ destination_root, "--skip-system-test" ] + + assert_compose_file do |compose_config| + assert_not_includes compose_config["services"]["rails-app"]["depends_on"], "selenium" + assert_not_includes compose_config["services"].keys, "selenium" + 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_compose_file do |compose_config| + assert_not_includes compose_config["services"]["rails-app"].keys, "depends_on" + 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/compose.yaml") + end + private def assert_node_files assert_file ".node-version" do |content| diff --git a/railties/test/generators/db_system_change_generator_test.rb b/railties/test/generators/db_system_change_generator_test.rb index 77ca07fba17e3..4e0e2a2ee5d0b 100644 --- a/railties/test/generators/db_system_change_generator_test.rb +++ b/railties/test/generators/db_system_change_generator_test.rb @@ -17,6 +17,7 @@ class ChangeGeneratorTest < Rails::Generators::TestCase ENTRY copy_dockerfile + copy_devcontainer_files end test "change to invalid database" do @@ -50,6 +51,28 @@ class ChangeGeneratorTest < Rails::Generators::TestCase assert_match "build-essential git libpq-dev", content assert_match "curl libvips postgresql-client", content end + + assert_file(".devcontainer/devcontainer.json") do |content| + assert_match(/"DB_HOST": "postgres"/, content) + end + + assert_compose_file do |compose_config| + assert_includes compose_config["services"]["rails-app"]["depends_on"], "postgres" + + expected_postgres_config = { + "image" => "postgres:16.1", + "restart" => "unless-stopped", + "networks" => ["default"], + "volumes" => ["postgres-data:/var/lib/postgresql/data"], + "environment" => { + "POSTGRES_USER" => "postgres", + "POSTGRES_PASSWORD" => "postgres" + } + } + + assert_equal expected_postgres_config, compose_config["services"]["postgres"] + assert_includes compose_config["volumes"].keys, "postgres-data" + end end test "change to mysql" do @@ -69,6 +92,28 @@ class ChangeGeneratorTest < Rails::Generators::TestCase assert_match "build-essential default-libmysqlclient-dev git", content assert_match "curl default-mysql-client libvips", content end + + assert_file(".devcontainer/devcontainer.json") do |content| + assert_match(/"DB_HOST": "mysql"/, content) + end + + assert_compose_file do |compose_config| + assert_includes compose_config["services"]["rails-app"]["depends_on"], "mysql" + + expected_postgres_config = { + "image" => "mysql/mysql-server:8.0", + "restart" => "unless-stopped", + "environment" => { + "MYSQL_ALLOW_EMPTY_PASSWORD" => true, + "MYSQL_ROOT_HOST" => "%" + }, + "volumes" => ["mysql-data:/var/lib/mysql"], + "networks" => ["default"], + } + + assert_equal expected_postgres_config, compose_config["services"]["mysql"] + assert_includes compose_config["volumes"].keys, "mysql-data" + end end test "change to sqlite3" do @@ -88,6 +133,10 @@ class ChangeGeneratorTest < Rails::Generators::TestCase assert_match "build-essential git libvips", content assert_match "curl libsqlite3-0 libvips", content end + + assert_file(".devcontainer/devcontainer.json") do |content| + assert_no_match(/"DB_HOST"/, content) + end end test "change to trilogy" do @@ -108,6 +157,27 @@ class ChangeGeneratorTest < Rails::Generators::TestCase assert_match "curl libvips", content assert_no_match "default-libmysqlclient-dev", content end + + assert_file(".devcontainer/devcontainer.json") do |content| + assert_match(/"DB_HOST": "mariadb"/, content) + end + + assert_compose_file do |compose_config| + assert_includes compose_config["services"]["rails-app"]["depends_on"], "mariadb" + + expected_postgres_config = { + "image" => "mariadb:10.5", + "restart" => "unless-stopped", + "networks" => ["dqefault"], + "volumes" => ["mariadb-data:/var/lib/mysql"], + "environment" => { + "MARIADB_ALLOW_EMPTY_ROOT_PASSWORD" => true, + }, + } + + assert_equal expected_postgres_config, compose_config["services"]["mariadb"] + assert_includes compose_config["volumes"].keys, "mariadb-data" + end end test "change from versioned gem to other versioned gem" do @@ -124,6 +194,23 @@ class ChangeGeneratorTest < Rails::Generators::TestCase assert_match 'gem "mysql2", "~> 0.5"', content end end + + test "change from db with devcontainer service to one without" do + copy_minimal_devcontainer_compose_file + + run_generator ["--to", "mysql"] + run_generator ["--to", "sqlite3", "--force"] + + assert_file(".devcontainer/devcontainer.json") do |content| + assert_no_match(/"DB_HOST"/, content) + end + + assert_compose_file do |compose_config| + assert_not_includes compose_config["services"]["rails-app"].keys, "depends_on" + assert_not_includes compose_config["services"].keys, "mysql" + assert_not_includes compose_config.keys, "volumes" + end + end end end end diff --git a/railties/test/generators/generators_test_helper.rb b/railties/test/generators/generators_test_helper.rb index e9f2d98713712..9e18c7adc3162 100644 --- a/railties/test/generators/generators_test_helper.rb +++ b/railties/test/generators/generators_test_helper.rb @@ -82,6 +82,25 @@ def copy_dockerfile File.write File.join(destination, "Dockerfile"), dockerfile end + def copy_devcontainer_files + destination = File.join(destination_root, ".devcontainer") + mkdir_p(destination) + + devcontainer_json = File.read(File.expand_path("../fixtures/.devcontainer/devcontainer.json", __dir__)) + File.write File.join(destination, "devcontainer.json"), devcontainer_json + + compose_yaml = File.read(File.expand_path("../fixtures/.devcontainer/compose.yaml", __dir__)) + File.write File.join(destination, "compose.yaml"), compose_yaml + end + + def copy_minimal_devcontainer_compose_file + destination = File.join(destination_root, ".devcontainer") + mkdir_p(destination) + + compose_yaml = File.read(File.expand_path("../fixtures/.devcontainer/compose-minimal.yaml", __dir__)) + File.write File.join(destination, "compose.yaml"), compose_yaml + end + def evaluate_template(file, locals = {}) erb = ERB.new(File.read(file), trim_mode: "-", eoutvar: "@output_buffer") context = Class.new do @@ -97,6 +116,12 @@ def evaluate_template_docker(file) erb.result() end + def assert_compose_file + assert_file ".devcontainer/compose.yaml" do |content| + yield YAML.load(content) + end + end + private def gemfile_locals {