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 {