From e8beb362d08589c152af24852779fa7104ad3aa0 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 10 Jan 2023 17:27:56 +0100 Subject: [PATCH] Add role concern with specialized cmds for job running --- lib/mrsk/commands/app.rb | 13 ++-- lib/mrsk/configuration.rb | 69 +++++++++++++------ lib/mrsk/configuration/role.rb | 63 +++++++++++++++++ lib/tasks/mrsk/app.rake | 24 ++++--- lib/tasks/mrsk/registry.rake | 2 +- test/app_command_test.rb | 4 +- test/configuration_role_test.rb | 45 +++++++++++++ test/configuration_test.rb | 115 +++++++++++++++++++++++++------- 8 files changed, 273 insertions(+), 62 deletions(-) create mode 100644 lib/mrsk/configuration/role.rb create mode 100644 test/configuration_role_test.rb diff --git a/lib/mrsk/commands/app.rb b/lib/mrsk/commands/app.rb index c5ab75fc..f59cd7f6 100644 --- a/lib/mrsk/commands/app.rb +++ b/lib/mrsk/commands/app.rb @@ -9,15 +9,18 @@ class Mrsk::Commands::App < Mrsk::Commands::Base docker :pull, config.absolute_image end - def run + def run(role: :web) + role = config.role(role) + docker :run, "-d", "--restart unless-stopped", "--name", config.service_with_version, "-e", redact("RAILS_MASTER_KEY=#{config.master_key}"), - *config.envs, - *config.labels, - config.absolute_image + *config.env_args, + *role.label_args, + config.absolute_image, + role.cmd end def start @@ -39,7 +42,7 @@ class Mrsk::Commands::App < Mrsk::Commands::Base def exec(*command) docker :exec, "-e", redact("RAILS_MASTER_KEY=#{config.master_key}"), - *config.envs, + *config.env_args, config.service_with_version, *command end diff --git a/lib/mrsk/configuration.rb b/lib/mrsk/configuration.rb index b3d8bc5c..cc4623f2 100644 --- a/lib/mrsk/configuration.rb +++ b/lib/mrsk/configuration.rb @@ -1,14 +1,21 @@ require "active_support/ordered_options" +require "active_support/core_ext/string/inquiry" require "erb" class Mrsk::Configuration - delegate :service, :image, :env, :registry, :ssh_user, to: :config, allow_nil: true + delegate :service, :image, :servers, :env, :registry, :ssh_user, to: :config, allow_nil: true - def self.load_file(file) - if file.exist? - new YAML.load(ERB.new(IO.read(file)).result).symbolize_keys - else - raise "Configuration file not found in #{file}" + class << self + def load_file(file) + if file.exist? + new YAML.load(ERB.new(IO.read(file)).result).symbolize_keys + else + raise "Configuration file not found in #{file}" + end + end + + def argumentize(argument, attributes) + attributes.flat_map { |k, v| [ argument, "#{k}=#{v}" ] } end end @@ -17,11 +24,39 @@ class Mrsk::Configuration ensure_required_keys_present end + + def roles + @roles ||= role_names.collect { |role_name| Role.new(role_name, config: self) } + end + + def role(name) + roles.detect { |r| r.name == name.to_s } + end + def hosts - ENV["HOSTS"] || config.servers + hosts = + case + when ENV["HOSTS"] + ENV["HOSTS"].split(",") + when ENV["ROLES"] + role_names = ENV["ROLES"].split(",") + roles.select { |r| role_names.include?(r.name) }.flat_map(&:hosts) + else + roles.flat_map(&:hosts) + end + + if hosts.any? + hosts + else + raise ArgumentError, "No hosts found" + end end + + def primary_host + role(:web).hosts.first end + def version @version ||= ENV["VERSION"] || `git rev-parse HEAD`.strip end @@ -38,18 +73,9 @@ class Mrsk::Configuration "#{service}-#{version}" end - def envs - parameterize "-e", env if env.present? - end - def labels - parameterize "--label", \ - "service" => service, - "traefik.http.routers.#{service}.rule" => "'PathPrefix(`/`)'", - "traefik.http.services.#{service}.loadbalancer.healthcheck.path" => "/up", - "traefik.http.services.#{service}.loadbalancer.healthcheck.interval" => "1s", - "traefik.http.middlewares.#{service}.retry.attempts" => "3", - "traefik.http.middlewares.#{service}.retry.initialinterval" => "500ms" + def env_args + self.class.argumentize "-e", config.env if config.env.present? end def ssh_options @@ -60,6 +86,7 @@ class Mrsk::Configuration ENV["RAILS_MASTER_KEY"] || File.read(Rails.root.join("config/master.key")) end + private attr_accessor :config @@ -73,7 +100,9 @@ class Mrsk::Configuration end end - def parameterize(param, hash) - hash.flat_map { |k, v| [ param, "#{k}=#{v}" ] } + def role_names + config.servers.is_a?(Array) ? [ "web" ] : config.servers.keys.sort end end + +require "mrsk/configuration/role" diff --git a/lib/mrsk/configuration/role.rb b/lib/mrsk/configuration/role.rb new file mode 100644 index 00000000..6a623bab --- /dev/null +++ b/lib/mrsk/configuration/role.rb @@ -0,0 +1,63 @@ +class Mrsk::Configuration::Role + delegate :argumentize, to: Mrsk::Configuration + + attr_accessor :name + + def initialize(name, config:) + @name, @config = name.inquiry, config + end + + def hosts + @hosts ||= extract_hosts_from_config + end + + def label_args + argumentize "--label", labels + end + + def cmd + specializations["cmd"] + end + + private + attr_accessor :config + + def extract_hosts_from_config + if config.servers.is_a?(Array) + config.servers + else + servers = config.servers[name] + servers.is_a?(Array) ? servers : servers["hosts"] + end + end + + def labels + if name.web? + default_labels.merge(traefik_labels) + else + default_labels + end + end + + def default_labels + { "service" => config.service, "role" => name } + end + + def traefik_labels + { + "traefik.http.routers.#{config.service}.rule" => "'PathPrefix(`/`)'", + "traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => "/up", + "traefik.http.services.#{config.service}.loadbalancer.healthcheck.interval" => "1s", + "traefik.http.middlewares.#{config.service}.retry.attempts" => "3", + "traefik.http.middlewares.#{config.service}.retry.initialinterval" => "500ms" + } + end + + def specializations + if config.servers.is_a?(Array) || config.servers[name].is_a?(Array) + { } + else + config.servers[name].without("hosts") + end + end +end diff --git a/lib/tasks/mrsk/app.rake b/lib/tasks/mrsk/app.rake index 0e62c9ca..9f8b78a6 100644 --- a/lib/tasks/mrsk/app.rake +++ b/lib/tasks/mrsk/app.rake @@ -19,15 +19,17 @@ namespace :mrsk do desc "Run app on servers (or start them if they've already been run)" task :run do - on(MRSK_CONFIG.hosts) do |host| - begin - execute *app.run - rescue SSHKit::Command::Failed => e - if e.message =~ /already in use/ - puts "Container with same version already deployed on #{host}, starting that instead" - execute *app.start, host: host - else - raise + MRSK_CONFIG.roles.each do |role| + on(MRSK_CONFIG.role(role).hosts) do |host| + begin + execute *app.run(role: role) + rescue SSHKit::Command::Failed => e + if e.message =~ /already in use/ + puts "Container with same version already deployed on #{host}, starting that instead" + execute *app.start, host: host + else + raise + end end end end @@ -64,13 +66,13 @@ namespace :mrsk do desc "Execute a custom task on the first defined server" task :once do - on(MRSK_CONFIG.hosts.first) { |host| puts capture(*app.exec(ENV["CMD"])) } + on(MRSK_CONFIG.primary_host) { |host| puts capture(*app.exec(ENV["CMD"])) } end namespace :once do desc "Execute Rails command on the first defined server, like CMD='runner \"puts %(Hello World)\"" task :rails do - on(MRSK_CONFIG.hosts.first) { puts capture(*app.exec("bin/rails", ENV["CMD"])) } + on(MRSK_CONFIG.primary_host) { puts capture(*app.exec("bin/rails", ENV["CMD"])) } end end end diff --git a/lib/tasks/mrsk/registry.rake b/lib/tasks/mrsk/registry.rake index 28dd5920..c5750a94 100644 --- a/lib/tasks/mrsk/registry.rake +++ b/lib/tasks/mrsk/registry.rake @@ -6,7 +6,7 @@ namespace :mrsk do namespace :registry do desc "Login to the registry locally and remotely" task :login do - run_locally { execute *registry.login } + run_locally { execute *registry.login } on(MRSK_CONFIG.hosts) { execute *registry.login } end diff --git a/test/app_command_test.rb b/test/app_command_test.rb index ff71b60e..e2636a4e 100644 --- a/test/app_command_test.rb +++ b/test/app_command_test.rb @@ -7,12 +7,12 @@ ENV["RAILS_MASTER_KEY"] = "456" class AppCommandTest < ActiveSupport::TestCase setup do - @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" } } + @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ] } @app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config) end test "run" do assert_equal \ - [:docker, :run, "-d", "--restart unless-stopped", "--name", "app-123", "-e", "RAILS_MASTER_KEY=456", "--label", "service=app", "--label", "traefik.http.routers.app.rule='PathPrefix(`/`)'", "--label", "traefik.http.services.app.loadbalancer.healthcheck.path=/up", "--label", "traefik.http.services.app.loadbalancer.healthcheck.interval=1s", "--label", "traefik.http.middlewares.app.retry.attempts=3", "--label", "traefik.http.middlewares.app.retry.initialinterval=500ms", "dhh/app:123"], @app.run + [:docker, :run, "-d", "--restart unless-stopped", "--name", "app-123", "-e", "RAILS_MASTER_KEY=456", "--label", "service=app", "--label", "role=web", "--label", "traefik.http.routers.app.rule='PathPrefix(`/`)'", "--label", "traefik.http.services.app.loadbalancer.healthcheck.path=/up", "--label", "traefik.http.services.app.loadbalancer.healthcheck.interval=1s", "--label", "traefik.http.middlewares.app.retry.attempts=3", "--label", "traefik.http.middlewares.app.retry.initialinterval=500ms", "dhh/app:123"], @app.run end end diff --git a/test/configuration_role_test.rb b/test/configuration_role_test.rb new file mode 100644 index 00000000..83bb4e02 --- /dev/null +++ b/test/configuration_role_test.rb @@ -0,0 +1,45 @@ +require "test_helper" +require "mrsk/configuration" + +ENV["VERSION"] = "123" + +class ConfigurationRoleTest < ActiveSupport::TestCase + setup do + @deploy = { + service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, + servers: [ "1.1.1.1", "1.1.1.2" ] + } + + @config = Mrsk::Configuration.new(@deploy) + + @deploy_with_roles = @deploy.dup.merge({ + servers: { + "web" => [ "1.1.1.1", "1.1.1.2" ], + "workers" => { + "hosts" => [ "1.1.1.3", "1.1.1.4" ], + "cmd" => "bin/jobs" + } + } + }) + + @config_with_roles = Mrsk::Configuration.new(@deploy_with_roles) + end + + test "hosts" do + assert_equal [ "1.1.1.1", "1.1.1.2" ], @config.role(:web).hosts + assert_equal [ "1.1.1.3", "1.1.1.4" ], @config_with_roles.role(:workers).hosts + end + + test "label args" do + assert_equal [ "--label", "service=app", "--label", "role=workers" ], @config_with_roles.role(:workers).label_args + end + + test "special label args for web" do + assert_equal [ "--label", "service=app", "--label", "role=web", "--label", "traefik.http.routers.app.rule='PathPrefix(`/`)'", "--label", "traefik.http.services.app.loadbalancer.healthcheck.path=/up", "--label", "traefik.http.services.app.loadbalancer.healthcheck.interval=1s", "--label", "traefik.http.middlewares.app.retry.attempts=3", "--label", "traefik.http.middlewares.app.retry.initialinterval=500ms"], @config.role(:web).label_args + end + + test "cmd" do + assert_nil @config.role(:web).cmd + assert_equal "bin/jobs", @config_with_roles.role(:workers).cmd + end +end diff --git a/test/configuration_test.rb b/test/configuration_test.rb index e1dc5399..406e9fbc 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -2,47 +2,116 @@ require "test_helper" require "mrsk/configuration" ENV["VERSION"] = "123" +ENV["RAILS_MASTER_KEY"] = "456" class ConfigurationTest < ActiveSupport::TestCase setup do - @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" } } + @deploy = { + service: "app", image: "dhh/app", + registry: { "username" => "dhh", "password" => "secret" }, + env: { "REDIS_URL" => "redis://x/y" }, + servers: [ "1.1.1.1", "1.1.1.2" ] + } + + @config = Mrsk::Configuration.new(@deploy) + + @deploy_with_roles = @deploy.dup.merge({ + servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => { "hosts" => [ "1.1.1.3", "1.1.1.4" ] } } }) + + @config_with_roles = Mrsk::Configuration.new(@deploy_with_roles) end test "ensure valid keys" do assert_raise(ArgumentError) do - Mrsk::Configuration.new(@config.tap { _1.delete(:service) }) - Mrsk::Configuration.new(@config.tap { _1.delete(:image) }) - Mrsk::Configuration.new(@config.tap { _1.delete(:registry) }) + Mrsk::Configuration.new(@deploy.tap { _1.delete(:service) }) + Mrsk::Configuration.new(@deploy.tap { _1.delete(:image) }) + Mrsk::Configuration.new(@deploy.tap { _1.delete(:registry) }) - Mrsk::Configuration.new(@config.tap { _1[:registry].delete("username") }) - Mrsk::Configuration.new(@config.tap { _1[:registry].delete("password") }) + Mrsk::Configuration.new(@deploy.tap { _1[:registry].delete("username") }) + Mrsk::Configuration.new(@deploy.tap { _1[:registry].delete("password") }) end end - test "repository" do - configuration = Mrsk::Configuration.new(@config) - assert_equal "dhh/app", configuration.repository + test "roles" do + assert_equal %w[ web ], @config.roles.collect(&:name) + assert_equal %w[ web workers ], @config_with_roles.roles.collect(&:name) + end - configuration = Mrsk::Configuration.new(@config.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) }) - assert_equal "ghcr.io/dhh/app", configuration.repository + test "role" do + assert_equal "web", @config.role(:web).name + assert_equal "workers", @config_with_roles.role(:workers).name + assert_nil @config.role(:missing) + end + + test "hosts" do + assert_equal [ "1.1.1.1", "1.1.1.2"], @config.hosts + assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @config_with_roles.hosts + end + + test "hosts from ENV" do + ENV["HOSTS"] = "1.1.1.5,1.1.1.6" + assert_equal [ "1.1.1.5", "1.1.1.6"], @config.hosts + ensure + ENV["HOSTS"] = nil + end + + test "hosts from ENV roles" do + ENV["ROLES"] = "web,workers" + assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @config_with_roles.hosts + + ENV["ROLES"] = "workers" + assert_equal [ "1.1.1.3", "1.1.1.4" ], @config_with_roles.hosts + ensure + ENV["ROLES"] = nil + end + + test "primary host" do + assert_equal "1.1.1.1", @config.primary_host + assert_equal "1.1.1.1", @config_with_roles.primary_host + end + + + test "version" do + assert_equal "123", @config.version + end + + test "repository" do + assert_equal "dhh/app", @config.repository + + config = Mrsk::Configuration.new(@deploy.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) }) + assert_equal "ghcr.io/dhh/app", config.repository end test "absolute image" do - configuration = Mrsk::Configuration.new(@config) - assert_equal "dhh/app:123", configuration.absolute_image + assert_equal "dhh/app:123", @config.absolute_image - configuration = Mrsk::Configuration.new(@config.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) }) - assert_equal "ghcr.io/dhh/app:123", configuration.absolute_image + config = Mrsk::Configuration.new(@deploy.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) }) + assert_equal "ghcr.io/dhh/app:123", config.absolute_image end + test "service with version" do + assert_equal "app-123", @config.service_with_version + end + + + test "env args" do + assert_equal [ "-e", "REDIS_URL=redis://x/y" ], @config.env_args + end + + test "ssh options" do + assert_equal "root", @config.ssh_options[:user] + + config = Mrsk::Configuration.new(@deploy.tap { |c| c[:ssh_user] = "app" }) + assert_equal "app", @config.ssh_options[:user] + end + + test "master key" do + assert_equal "456", @config.master_key + end + + test "erb evaluation of yml config" do - configuration = Mrsk::Configuration.load_file Pathname.new(File.expand_path("fixtures/deploy.erb.yml", __dir__)) - assert_equal "my-user", configuration.registry["username"] - end - - test "labels" do - configuration = Mrsk::Configuration.new(@config) - assert_equal ["--label", "service=app", "--label", "traefik.http.routers.app.rule='PathPrefix(`/`)'", "--label", "traefik.http.services.app.loadbalancer.healthcheck.path=/up", "--label", "traefik.http.services.app.loadbalancer.healthcheck.interval=1s", "--label", "traefik.http.middlewares.app.retry.attempts=3", "--label", "traefik.http.middlewares.app.retry.initialinterval=500ms"], - configuration.labels + config = Mrsk::Configuration.load_file Pathname.new(File.expand_path("fixtures/deploy.erb.yml", __dir__)) + assert_equal "my-user", config.registry["username"] end end