diff --git a/Gemfile.lock b/Gemfile.lock index d79bedff..815761d0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,90 +13,79 @@ PATH GEM remote: https://rubygems.org/ specs: - actionpack (7.0.4) - actionview (= 7.0.4) - activesupport (= 7.0.4) + actionpack (7.0.4.3) + actionview (= 7.0.4.3) + activesupport (= 7.0.4.3) rack (~> 2.0, >= 2.2.0) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actionview (7.0.4) - activesupport (= 7.0.4) + actionview (7.0.4.3) + activesupport (= 7.0.4.3) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activesupport (7.0.4) + activesupport (7.0.4.3) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) bcrypt_pbkdf (1.1.0) builder (3.2.4) - concurrent-ruby (1.1.10) + concurrent-ruby (1.2.2) crass (1.0.6) debug (1.7.1) - irb (>= 1.5.0) - reline (>= 0.3.1) dotenv (2.8.1) ed25519 (1.3.0) erubi (1.12.0) i18n (1.12.0) concurrent-ruby (~> 1.0) - io-console (0.6.0) - irb (1.6.2) - reline (>= 0.3.0) loofah (2.19.1) crass (~> 1.0.2) nokogiri (>= 1.5.9) method_source (1.0.0) - minitest (5.17.0) + minitest (5.18.0) mocha (2.0.2) ruby2_keywords (>= 0.0.5) net-scp (4.0.0) net-ssh (>= 2.6.5, < 8.0.0) - net-ssh (7.0.1) - nokogiri (1.14.0-arm64-darwin) + net-ssh (7.1.0) + nokogiri (1.14.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.14.0-x86_64-darwin) + nokogiri (1.14.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.14.0-x86_64-linux) + nokogiri (1.14.2-x86_64-linux) racc (~> 1.4) racc (1.6.2) - rack (2.2.5) - rack-test (2.0.2) + rack (2.2.6.4) + rack-test (2.1.0) rack (>= 1.3) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.4.4) + rails-html-sanitizer (1.5.0) loofah (~> 2.19, >= 2.19.1) - railties (7.0.4) - actionpack (= 7.0.4) - activesupport (= 7.0.4) + railties (7.0.4.3) + actionpack (= 7.0.4.3) + activesupport (= 7.0.4.3) method_source rake (>= 12.2) thor (~> 1.0) zeitwerk (~> 2.5) rake (13.0.6) - reline (0.3.2) - io-console (~> 0.5) ruby2_keywords (0.0.5) - sshkit (1.21.3) + sshkit (1.21.4) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) thor (1.2.1) - tzinfo (2.0.5) + tzinfo (2.0.6) concurrent-ruby (~> 1.0) - zeitwerk (2.6.6) + zeitwerk (2.6.7) PLATFORMS - arm64-darwin-20 - arm64-darwin-21 - arm64-darwin-22 - x86_64-darwin-20 - x86_64-darwin-21 - x86_64-darwin-22 + arm64-darwin + x86_64-darwin x86_64-linux DEPENDENCIES diff --git a/README.md b/README.md index 9c3f92b8..b67e4218 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ MRSK deploys web apps anywhere from bare metal to cloud VMs using Docker with ze Watch the screencast: https://www.youtube.com/watch?v=LL1cV2FXZ5I -Join us on Discord: https://discord.gg/DQETs3Pm +Join us on Discord: https://discord.gg/YgHVT7GCXS ## Installation @@ -440,6 +440,46 @@ traefik: host_port: 8080 ``` +### Configure docker options for traefik + +We allow users to pass additional docker options to the trafik container like + +```yaml +traefik: + options: + publish: + - 8080:8080 + volumes: + - /tmp/example.json:/tmp/example.json + memory: 512m +``` + +This will start the traefik container with a command like: `docker run ... --volume /tmp/example.json:/tmp/example.json --publish 8080:8080 ` + + +### Configure alternate entrypoints for traefik + +You can configure multiple entrypoints for traefik like so: + +```yaml +service: myservice + +labels: + traefik.tcp.routers.other.rule: 'HostSNI(`*`)' + traefik.tcp.routers.other.entrypoints: otherentrypoint + traefik.tcp.services.other.loadbalancer.server.port: 9000 + traefik.http.routers.myservice.entrypoints: web + traefik.http.services.myservice.loadbalancer.server.port: 8080 + +traefik: + options: + publish: + - 9000:9000 + args: + entrypoints.web.address: ':80' + entrypoints.otherentrypoint.address: ':9000' +``` + ### Configuring build args for new images Build arguments that aren't secret can also be configured: @@ -459,7 +499,7 @@ FROM ruby:$RUBY_VERSION-slim as base ### Using accessories for database, cache, search services -You can manage your accessory services via MRSK as well. The services will build off public images, and will not be automatically updated when you deploy: +You can manage your accessory services via MRSK as well. Accessories are long-lived services that your app depends on. They are not updated when you deploy. ```yaml accessories: @@ -480,10 +520,16 @@ accessories: port: "36379:6379" volumes: - /var/lib/redis:/data + internal-example: + image: registry.digitalocean.com/user/otherservice:latest + host: 1.1.1.5 + port: 44444 ``` Now run `mrsk accessory start mysql` to start the MySQL server on 1.1.1.3. See `mrsk accessory` for all the commands possible. +Accessory images must be public or tagged in your private registry. + ### Using Cron You can use a specific container to run your Cron jobs: diff --git a/lib/mrsk/cli/accessory.rb b/lib/mrsk/cli/accessory.rb index 834c7dec..36f5cd3c 100644 --- a/lib/mrsk/cli/accessory.rb +++ b/lib/mrsk/cli/accessory.rb @@ -9,6 +9,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base upload(name) on(accessory.host) do + execute *MRSK.registry.login execute *MRSK.auditor.record("Booted #{name} accessory"), verbosity: :debug execute *accessory.run end diff --git a/lib/mrsk/cli/app.rb b/lib/mrsk/cli/app.rb index 796cd367..0898a509 100644 --- a/lib/mrsk/cli/app.rb +++ b/lib/mrsk/cli/app.rb @@ -37,7 +37,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base desc "start", "Start existing app container on servers" def start on(MRSK.hosts) do - execute *MRSK.auditor.record("Started app version #{MRSK.version}"), verbosity: :debug + execute *MRSK.auditor.record("Started app version #{MRSK.config.version}"), verbosity: :debug execute *MRSK.app.start, raise_on_non_zero_exit: false end end diff --git a/lib/mrsk/cli/base.rb b/lib/mrsk/cli/base.rb index c7a87964..4e0f2104 100644 --- a/lib/mrsk/cli/base.rb +++ b/lib/mrsk/cli/base.rb @@ -39,14 +39,6 @@ module Mrsk::Cli def initialize_commander(options) MRSK.tap do |commander| - commander.config_file = Pathname.new(File.expand_path(options[:config_file])) - commander.destination = options[:destination] - commander.version = options[:version] - - commander.specific_hosts = options[:hosts]&.split(",") - commander.specific_roles = options[:roles]&.split(",") - commander.specific_primary! if options[:primary] - if options[:verbose] ENV["VERBOSE"] = "1" # For backtraces via cli/start commander.verbosity = :debug @@ -55,6 +47,15 @@ module Mrsk::Cli if options[:quiet] commander.verbosity = :error end + + commander.configure \ + config_file: Pathname.new(File.expand_path(options[:config_file])), + destination: options[:destination], + version: options[:version] + + commander.specific_hosts = options[:hosts]&.split(",") + commander.specific_roles = options[:roles]&.split(",") + commander.specific_primary! if options[:primary] end end diff --git a/lib/mrsk/cli/build.rb b/lib/mrsk/cli/build.rb index 3e8912d7..c9845e4f 100644 --- a/lib/mrsk/cli/build.rb +++ b/lib/mrsk/cli/build.rb @@ -29,7 +29,7 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base desc "pull", "Pull app image from registry onto servers" def pull on(MRSK.hosts) do - execute *MRSK.auditor.record("Pulled image with version #{MRSK.version}"), verbosity: :debug + execute *MRSK.auditor.record("Pulled image with version #{MRSK.config.version}"), verbosity: :debug execute *MRSK.builder.clean, raise_on_non_zero_exit: false execute *MRSK.builder.pull end diff --git a/lib/mrsk/cli/main.rb b/lib/mrsk/cli/main.rb index 328af480..eb441d30 100644 --- a/lib/mrsk/cli/main.rb +++ b/lib/mrsk/cli/main.rb @@ -40,7 +40,7 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base invoke "mrsk:cli:prune:all", [], invoke_options end - audit_broadcast "Deployed app in #{runtime.to_i} seconds" unless options[:skip_broadcast] + audit_broadcast "Deployed #{service_version} in #{runtime.round} seconds" unless options[:skip_broadcast] end desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login" @@ -63,29 +63,32 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base invoke "mrsk:cli:app:boot", [], invoke_options end - audit_broadcast "Redeployed app in #{runtime.to_i} seconds" unless options[:skip_broadcast] + audit_broadcast "Redeployed #{service_version} in #{runtime.round} seconds" unless options[:skip_broadcast] end desc "rollback [VERSION]", "Rollback app to VERSION" def rollback(version) - MRSK.version = version + MRSK.config.version = version if container_name_available?(MRSK.config.service_with_version) say "Start version #{version}, then wait #{MRSK.config.readiness_delay}s for app to boot before stopping the old version...", :magenta cli = self + old_version = nil on(MRSK.hosts) do |host| old_version = capture_with_info(*MRSK.app.current_running_version).strip.presence execute *MRSK.app.start - sleep MRSK.config.readiness_delay + if old_version + sleep MRSK.config.readiness_delay - execute *MRSK.app.stop(version: old_version), raise_on_non_zero_exit: false + execute *MRSK.app.stop(version: old_version), raise_on_non_zero_exit: false + end end - audit_broadcast "Rolled back app to version #{version}" unless options[:skip_broadcast] + audit_broadcast "Rolled back #{service_version(Mrsk::Utils.abbreviate_version(old_version))} to #{service_version}" unless options[:skip_broadcast] else say "The app version '#{version}' is not available as a container (use 'mrsk app containers' for available versions)", :red end @@ -203,4 +206,8 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base on(host) { container_names = capture_with_info(*MRSK.app.list_container_names).split("\n") } Array(container_names).include?(container_name) end + + def service_version(version = MRSK.config.abbreviated_version) + [ MRSK.config.service, version ].compact.join("@") + end end diff --git a/lib/mrsk/cli/templates/deploy.yml b/lib/mrsk/cli/templates/deploy.yml index 55c53149..7d710632 100644 --- a/lib/mrsk/cli/templates/deploy.yml +++ b/lib/mrsk/cli/templates/deploy.yml @@ -13,6 +13,8 @@ registry: # Specify the registry server, if you're not using Docker Hub # server: registry.digitalocean.com / ghcr.io / ... username: my-user + + # Always use an access token rather than real password when possible. password: - MRSK_REGISTRY_PASSWORD diff --git a/lib/mrsk/commander.rb b/lib/mrsk/commander.rb index be2fd2e3..5aa24fc7 100644 --- a/lib/mrsk/commander.rb +++ b/lib/mrsk/commander.rb @@ -1,19 +1,26 @@ require "active_support/core_ext/enumerable" +require "active_support/core_ext/module/delegation" class Mrsk::Commander - attr_accessor :config_file, :destination, :verbosity, :version + attr_accessor :verbosity - def initialize(config_file: nil, destination: nil, verbosity: :info) - @config_file, @destination, @verbosity = config_file, destination, verbosity + def initialize + self.verbosity = :info end + def config - @config ||= \ - Mrsk::Configuration - .create_from(config_file, destination: destination, version: cascading_version) - .tap { |config| configure_sshkit_with(config) } + @config ||= Mrsk::Configuration.create_from(**@config_kwargs).tap do |config| + @config_kwargs = nil + configure_sshkit_with(config) + end end + def configure(**kwargs) + @config, @config_kwargs = nil, kwargs + end + + attr_accessor :specific_hosts def specific_primary! @@ -90,26 +97,15 @@ class Mrsk::Commander SSHKit.config.output_verbosity = old_level end + # Test-induced damage! def reset - @config = @config_file = @destination = @version = nil + @config = nil @app = @builder = @traefik = @registry = @prune = @auditor = nil @verbosity = :info end private - def cascading_version - version.presence || ENV["VERSION"] || current_commit_hash - end - - def current_commit_hash - if system("git rev-parse") - `git rev-parse HEAD`.strip - else - raise "Can't use commit hash as version, no git repository found in #{Dir.pwd}" - end - end - # Lazy setup of SSHKit def configure_sshkit_with(config) SSHKit::Backend::Netssh.configure { |ssh| ssh.ssh_options = config.ssh_options } diff --git a/lib/mrsk/commands/accessory.rb b/lib/mrsk/commands/accessory.rb index adad2b43..d3d3215d 100644 --- a/lib/mrsk/commands/accessory.rb +++ b/lib/mrsk/commands/accessory.rb @@ -1,6 +1,6 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base attr_reader :accessory_config - delegate :service_name, :image, :host, :port, :files, :directories, :env_args, :volume_args, :label_args, to: :accessory_config + delegate :service_name, :image, :host, :port, :files, :directories, :publish_args, :env_args, :volume_args, :label_args, to: :accessory_config def initialize(config, name:) super(config) @@ -12,8 +12,8 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base "--name", service_name, "--detach", "--restart", "unless-stopped", - "--publish", port, *config.logging_args, + *publish_args, *env_args, *volume_args, *label_args, diff --git a/lib/mrsk/commands/traefik.rb b/lib/mrsk/commands/traefik.rb index 1f6b8cc4..098b7e29 100644 --- a/lib/mrsk/commands/traefik.rb +++ b/lib/mrsk/commands/traefik.rb @@ -10,6 +10,7 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base "--publish", port, "--volume", "/var/run/docker.sock:/var/run/docker.sock", *config.logging_args, + *docker_options_args, "traefik", "--providers.docker", "--log.level=DEBUG", @@ -49,11 +50,15 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik" end - def port + def port "#{host_port}:#{CONTAINER_PORT}" end private + def docker_options_args + optionize(config.traefik["options"] || {}) + end + def cmd_option_args if args = config.traefik["args"] optionize args, with: "=" diff --git a/lib/mrsk/configuration.rb b/lib/mrsk/configuration.rb index 7dc6f139..789b9d1d 100644 --- a/lib/mrsk/configuration.rb +++ b/lib/mrsk/configuration.rb @@ -9,21 +9,21 @@ class Mrsk::Configuration delegate :service, :image, :servers, :env, :labels, :registry, :builder, :logging, to: :raw_config, allow_nil: true delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils - attr_accessor :version attr_accessor :destination attr_accessor :raw_config class << self - def create_from(base_config_file, destination: nil, version: "missing") - new(load_config_file(base_config_file).tap do |config| - if destination - config.deep_merge! \ - load_config_file destination_config_file(base_config_file, destination) - end - end, destination: destination, version: version) + def create_from(config_file:, destination: nil, version: nil) + raw_config = load_config_files(config_file, *destination_config_file(config_file, destination)) + + new raw_config, destination: destination, version: version end private + def load_config_files(*files) + files.inject({}) { |config, file| config.deep_merge! load_config_file(file) } + end + def load_config_file(file) if file.exist? YAML.load(ERB.new(IO.read(file)).result).symbolize_keys @@ -33,19 +33,31 @@ class Mrsk::Configuration end def destination_config_file(base_config_file, destination) - dir, basename = base_config_file.split - dir.join basename.to_s.remove(".yml") + ".#{destination}.yml" + base_config_file.sub_ext(".#{destination}.yml") if destination end end - def initialize(raw_config, destination: nil, version: "missing", validate: true) + def initialize(raw_config, destination: nil, version: nil, validate: true) @raw_config = ActiveSupport::InheritableOptions.new(raw_config) @destination = destination - @version = version + @declared_version = version valid? if validate end + def version=(version) + @declared_version = version + end + + def version + @declared_version.presence || ENV["VERSION"] || current_commit_hash + end + + def abbreviated_version + Mrsk::Utils.abbreviate_version(version) + end + + def roles @roles ||= role_names.collect { |role_name| Role.new(role_name, config: self) } end @@ -68,7 +80,7 @@ class Mrsk::Configuration end def primary_web_host - role(:web).hosts.first + role(:web).primary_host end def traefik_hosts @@ -194,6 +206,12 @@ class Mrsk::Configuration raise ArgumentError, "You must specify a password for the registry in config/deploy.yml (or set the ENV variable if that's used)" end + roles.each do |role| + if role.hosts.empty? + raise ArgumentError, "No servers specified for the #{role.name} role" + end + end + true end @@ -208,4 +226,13 @@ class Mrsk::Configuration def role_names raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort end + + def current_commit_hash + @current_commit_hash ||= + if system("git rev-parse") + `git rev-parse HEAD`.strip + else + raise "Can't use commit hash as version, no git repository found in #{Dir.pwd}" + end + end end diff --git a/lib/mrsk/configuration/accessory.rb b/lib/mrsk/configuration/accessory.rb index 58c4b125..f1051423 100644 --- a/lib/mrsk/configuration/accessory.rb +++ b/lib/mrsk/configuration/accessory.rb @@ -20,13 +20,15 @@ class Mrsk::Configuration::Accessory end def port - if specifics["port"].to_s.include?(":") - specifics["port"] - else - "#{specifics["port"]}:#{specifics["port"]}" + if port = specifics["port"]&.to_s + port.include?(":") ? port : "#{port}:#{port}" end end + def publish_args + argumentize "--publish", port if port + end + def labels default_labels.merge(specifics["labels"] || {}) end diff --git a/lib/mrsk/configuration/role.rb b/lib/mrsk/configuration/role.rb index f3e20e9a..bfc42fbe 100644 --- a/lib/mrsk/configuration/role.rb +++ b/lib/mrsk/configuration/role.rb @@ -7,6 +7,10 @@ class Mrsk::Configuration::Role @name, @config = name.inquiry, config end + def primary_host + hosts.first + end + def hosts @hosts ||= extract_hosts_from_config end @@ -55,7 +59,7 @@ class Mrsk::Configuration::Role config.servers else servers = config.servers[name] - servers.is_a?(Array) ? servers : servers["hosts"] + servers.is_a?(Array) ? servers : Array(servers["hosts"]) end end diff --git a/lib/mrsk/utils.rb b/lib/mrsk/utils.rb index 1e763250..08c0c0fa 100644 --- a/lib/mrsk/utils.rb +++ b/lib/mrsk/utils.rb @@ -26,14 +26,19 @@ module Mrsk::Utils # Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option. def optionize(args, with: nil) options = if with - args.collect { |(key, value)| value == true ? "--#{key}" : "--#{key}#{with}#{escape_shell_value(value)}" } + flatten_args(args).collect { |(key, value)| value == true ? "--#{key}" : "--#{key}#{with}#{escape_shell_value(value)}" } else - args.collect { |(key, value)| [ "--#{key}", value == true ? nil : escape_shell_value(value) ] } + flatten_args(args).collect { |(key, value)| [ "--#{key}", value == true ? nil : escape_shell_value(value) ] } end options.flatten.compact end + # Flattens a one-to-many structure into an array of two-element arrays each containing a key-value pair + def flatten_args(args) + args.flat_map { |key, value| value.try(:map) { |entry| [key, entry] } || [ [ key, value ] ] } + end + # Copied from SSHKit::Backend::Abstract#redact to be available inside Commands classes def redact(arg) # Used in execute_command to hide redact() args a user passes in arg.to_s.extend(SSHKit::Redaction) # to_s due to our inability to extend Integer, etc @@ -43,4 +48,9 @@ module Mrsk::Utils def escape_shell_value(value) value.to_s.dump.gsub(/`/, '\\\\`') end + + # Abbreviate a git revhash for concise display + def abbreviate_version(version) + version[0...7] if version + end end diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index 891cd67a..8d2fe435 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -6,7 +6,8 @@ class CliAccessoryTest < CliTestCase Mrsk::Cli::Accessory.any_instance.expects(:upload).with("mysql") run_command("boot", "mysql").tap do |output| - assert_match "docker run --name app-mysql --detach --restart unless-stopped --publish 3306:3306 -e [REDACTED] -e MYSQL_ROOT_HOST=\"%\" --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output + assert_match /docker login.*on 1.1.1.3/, output + assert_match "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=10m --publish 3306:3306 -e [REDACTED] -e MYSQL_ROOT_HOST=\"%\" --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output end end @@ -17,8 +18,10 @@ class CliAccessoryTest < CliTestCase Mrsk::Cli::Accessory.any_instance.expects(:upload).with("redis") run_command("boot", "all").tap do |output| - assert_match "docker run --name app-mysql --detach --restart unless-stopped --publish 3306:3306 -e [REDACTED] -e MYSQL_ROOT_HOST=\"%\" --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output - assert_match "docker run --name app-redis --detach --restart unless-stopped --publish 6379:6379 --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.4", output + assert_match /docker login.*on 1.1.1.3/, output + assert_match /docker login.*on 1.1.1.4/, output + assert_match "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=10m --publish 3306:3306 -e [REDACTED] -e MYSQL_ROOT_HOST=\"%\" --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output + assert_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=10m --publish 6379:6379 --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.4", output end end diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index f72741dd..151fa7b8 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -88,11 +88,23 @@ class CliMainTest < CliTestCase test "rollback good version" do Mrsk::Cli::Main.any_instance.stubs(:container_name_available?).returns(true) + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:docker, :ps, "--filter", "label=service=app", "--format", "\"{{.Names}}\"", "|", "sed 's/-/\\n/g'", "|", "tail -n 1").returns("version-to-rollback\n").times(2) run_command("rollback", "123").tap do |output| - assert_match /Start version 123/, output - assert_match /docker ps -q --filter label=service=app | xargs docker stop/, output - assert_match /docker start app-123/, output + assert_match "Start version 123", output + assert_match "docker start app-123", output + assert_match "docker container ls --all --filter name=app-version-to-rollback --quiet | xargs docker stop", output, "Should stop the container that was previously running" + end + end + + test "rollback without old version" do + Mrsk::Cli::Main.any_instance.stubs(:container_name_available?).returns(true) + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:docker, :ps, "--filter", "label=service=app", "--format", "\"{{.Names}}\"", "|", "sed 's/-/\\n/g'", "|", "tail -n 1").returns("").times(2) + + run_command("rollback", "123").tap do |output| + assert_match "Start version 123", output + assert_match "docker start app-123", output + assert_no_match "docker stop", output end end @@ -114,7 +126,7 @@ class CliMainTest < CliTestCase end test "config" do - run_command("config").tap do |output| + run_command("config", config_file: "deploy_with_accessories").tap do |output| config = YAML.load(output) assert_equal ["web"], config[:roles] @@ -126,6 +138,32 @@ class CliMainTest < CliTestCase end end + test "config with roles" do + run_command("config", config_file: "deploy_with_roles").tap do |output| + config = YAML.load(output) + + assert_equal ["web", "workers"], config[:roles] + assert_equal ["1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4"], config[:hosts] + assert_equal "999", config[:version] + assert_equal "registry.digitalocean.com/dhh/app", config[:repository] + assert_equal "registry.digitalocean.com/dhh/app:999", config[:absolute_image] + assert_equal "app-999", config[:service_with_version] + end + end + + test "config with destination" do + run_command("config", "-d", "world", config_file: "deploy_for_dest").tap do |output| + config = YAML.load(output) + + assert_equal ["web"], config[:roles] + assert_equal ["1.1.1.1", "1.1.1.2"], config[:hosts] + assert_equal "999", config[:version] + assert_equal "registry.digitalocean.com/dhh/app", config[:repository] + assert_equal "registry.digitalocean.com/dhh/app:999", config[:absolute_image] + assert_equal "app-999", config[:service_with_version] + end + end + test "init" do Pathname.any_instance.expects(:exist?).returns(false).twice FileUtils.stubs(:mkdir_p) @@ -215,7 +253,7 @@ class CliMainTest < CliTestCase end private - def run_command(*command) - stdouted { Mrsk::Cli::Main.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) } + def run_command(*command, config_file: "deploy_with_accessories") + stdouted { Mrsk::Cli::Main.start([*command, "-c", "test/fixtures/#{config_file}.yml"]) } end end diff --git a/test/commander_test.rb b/test/commander_test.rb index b4ce24dc..278d9896 100644 --- a/test/commander_test.rb +++ b/test/commander_test.rb @@ -2,23 +2,15 @@ require "test_helper" class CommanderTest < ActiveSupport::TestCase setup do - @mrsk = Mrsk::Commander.new config_file: Pathname.new(File.expand_path("fixtures/deploy_with_roles.yml", __dir__)) + @mrsk = Mrsk::Commander.new.tap do |mrsk| + mrsk.configure config_file: Pathname.new(File.expand_path("fixtures/deploy_with_roles.yml", __dir__)) + end end test "lazy configuration" do assert_equal Mrsk::Configuration, @mrsk.config.class end - test "commit hash as version" do - assert_equal `git rev-parse HEAD`.strip, @mrsk.config.version - end - - test "commit hash as version but not in git" do - @mrsk.expects(:system).with("git rev-parse").returns(nil) - error = assert_raises(RuntimeError) { @mrsk.config } - assert_match /no git repository found/, error.message - end - test "overwriting hosts" do assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @mrsk.hosts diff --git a/test/commands/accessory_test.rb b/test/commands/accessory_test.rb index 9f5ca67b..ac38b38e 100644 --- a/test/commands/accessory_test.rb +++ b/test/commands/accessory_test.rb @@ -3,11 +3,11 @@ require "test_helper" class CommandsAccessoryTest < ActiveSupport::TestCase setup do @config = { - service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, + service: "app", image: "dhh/app", registry: { "server" => "private.registry", "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], accessories: { "mysql" => { - "image" => "mysql:8.0", + "image" => "private.registry/mysql:8.0", "host" => "1.1.1.5", "port" => "3306", "env" => { @@ -32,6 +32,10 @@ class CommandsAccessoryTest < ActiveSupport::TestCase "volumes" => [ "/var/lib/redis:/data" ] + }, + "busybox" => { + "image" => "busybox:latest", + "host" => "1.1.1.7" } } } @@ -45,24 +49,16 @@ class CommandsAccessoryTest < ActiveSupport::TestCase test "run" do assert_equal \ - "docker run --name app-mysql --detach --restart unless-stopped --publish 3306:3306 -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" --label service=\"app-mysql\" mysql:8.0", + "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=10m --publish 3306:3306 -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" --label service=\"app-mysql\" private.registry/mysql:8.0", new_command(:mysql).run.join(" ") assert_equal \ - "docker run --name app-redis --detach --restart unless-stopped --publish 6379:6379 -e SOMETHING=\"else\" --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest", + "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=10m --publish 6379:6379 -e SOMETHING=\"else\" --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest", new_command(:redis).run.join(" ") - end - - test "run with logging config" do - @config[:logging] = { "driver" => "local", "options" => { "max-size" => "10m", "max-file" => "3" } } assert_equal \ - "docker run --name app-mysql --detach --restart unless-stopped --publish 3306:3306 --log-driver local --log-opt max-size=\"10m\" --log-opt max-file=\"3\" -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" --label service=\"app-mysql\" mysql:8.0", - new_command(:mysql).run.join(" ") - - assert_equal \ - "docker run --name app-redis --detach --restart unless-stopped --publish 6379:6379 --log-driver local --log-opt max-size=\"10m\" --log-opt max-file=\"3\" -e SOMETHING=\"else\" --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest", - new_command(:redis).run.join(" ") + "docker run --name app-busybox --detach --restart unless-stopped --log-opt max-size=10m --label service=\"app-busybox\" busybox:latest", + new_command(:busybox).run.join(" ") end test "start" do @@ -86,7 +82,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase test "execute in new container" do assert_equal \ - "docker run --rm -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" mysql:8.0 mysql -u root", + "docker run --rm -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" private.registry/mysql:8.0 mysql -u root", new_command(:mysql).execute_in_new_container("mysql", "-u", "root").join(" ") end @@ -97,8 +93,8 @@ class CommandsAccessoryTest < ActiveSupport::TestCase end test "execute in new container over ssh" do - new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do - assert_match %r|docker run -it --rm -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" mysql:8.0 mysql -u root|, + @mysql.stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do + assert_match %r|docker run -it --rm -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" private.registry/mysql:8.0 mysql -u root|, new_command(:mysql).execute_in_new_container_over_ssh("mysql", "-u", "root") end end diff --git a/test/commands/traefik_test.rb b/test/commands/traefik_test.rb index 9fa8b70f..523a1de0 100644 --- a/test/commands/traefik_test.rb +++ b/test/commands/traefik_test.rb @@ -19,6 +19,39 @@ class CommandsTraefikTest < ActiveSupport::TestCase new_command.run.join(" ") end + test "run with ports configured" do + assert_equal \ + "docker run --name traefik --detach --restart unless-stopped --log-opt max-size=10m --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock traefik --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", + new_command.run.join(" ") + + @config[:traefik]["options"] = {"publish" => %w[9000:9000 9001:9001]} + assert_equal \ + "docker run --name traefik --detach --restart unless-stopped --log-opt max-size=10m --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --publish \"9000:9000\" --publish \"9001:9001\" traefik --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", + new_command.run.join(" ") + end + + test "run with volumes configured" do + assert_equal \ + "docker run --name traefik --detach --restart unless-stopped --log-opt max-size=10m --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock traefik --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", + new_command.run.join(" ") + + @config[:traefik]["options"] = {"volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json] } + assert_equal \ + "docker run --name traefik --detach --restart unless-stopped --log-opt max-size=10m --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" traefik --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", + new_command.run.join(" ") + end + + test "run with several options configured" do + assert_equal \ + "docker run --name traefik --detach --restart unless-stopped --log-opt max-size=10m --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock traefik --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", + new_command.run.join(" ") + + @config[:traefik]["options"] = {"volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json], "publish" => %w[8080:8080], "memory" => "512m"} + assert_equal \ + "docker run --name traefik --detach --restart unless-stopped --log-opt max-size=10m --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" --publish \"8080:8080\" --memory \"512m\" traefik --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", + new_command.run.join(" ") + end + test "run without configuration" do @config.delete(:traefik) diff --git a/test/configuration_test.rb b/test/configuration_test.rb index 8e3fad42..53558c93 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -3,6 +3,7 @@ require "test_helper" class ConfigurationTest < ActiveSupport::TestCase setup do ENV["RAILS_MASTER_KEY"] = "456" + ENV["VERSION"] = "missing" @deploy = { service: "app", image: "dhh/app", @@ -22,17 +23,23 @@ class ConfigurationTest < ActiveSupport::TestCase end teardown do - ENV["RAILS_MASTER_KEY"] = nil + ENV.delete("RAILS_MASTER_KEY") + ENV.delete("VERSION") end - test "ensure valid keys" do - assert_raise(ArgumentError) do - Mrsk::Configuration.new(@deploy.tap { _1.delete(:service) }) - Mrsk::Configuration.new(@deploy.tap { _1.delete(:image) }) - Mrsk::Configuration.new(@deploy.tap { _1.delete(:registry) }) + %i[ service image registry ].each do |key| + test "#{key} config required" do + assert_raise(ArgumentError) do + Mrsk::Configuration.new @deploy.tap { _1.delete key } + end + end + end - Mrsk::Configuration.new(@deploy.tap { _1[:registry].delete("username") }) - Mrsk::Configuration.new(@deploy.tap { _1[:registry].delete("password") }) + %w[ username password ].each do |key| + test "registry #{key} required" do + assert_raise(ArgumentError) do + Mrsk::Configuration.new @deploy.tap { _1[:registry].delete key } + end end end @@ -67,8 +74,20 @@ class ConfigurationTest < ActiveSupport::TestCase end test "version" do - assert_equal "missing", @config.version - assert_equal "123", Mrsk::Configuration.new(@deploy, version: "123").version + ENV.delete("VERSION") + + @config.expects(:system).with("git rev-parse").returns(nil) + error = assert_raises(RuntimeError) { @config.version} + assert_match /no git repository found/, error.message + + @config.expects(:current_commit_hash).returns("git-version") + assert_equal "git-version", @config.version + + ENV["VERSION"] = "env-version" + assert_equal "env-version", @config.version + + @config.version = "arg-version" + assert_equal "arg-version", @config.version end test "repository" do @@ -135,6 +154,39 @@ class ConfigurationTest < ActiveSupport::TestCase test "valid config" do assert @config.valid? + assert @config_with_roles.valid? + end + + test "hosts required for all roles" do + # Empty server list for implied web role + assert_raises(ArgumentError) do + Mrsk::Configuration.new @deploy.merge(servers: []) + end + + # Empty server list + assert_raises(ArgumentError) do + Mrsk::Configuration.new @deploy.merge(servers: { "web" => [] }) + end + + # Missing hosts key + assert_raises(ArgumentError) do + Mrsk::Configuration.new @deploy.merge(servers: { "web" => {} }) + end + + # Empty hosts list + assert_raises(ArgumentError) do + Mrsk::Configuration.new @deploy.merge(servers: { "web" => { "hosts" => [] } }) + end + + # Nil hosts + assert_raises(ArgumentError) do + Mrsk::Configuration.new @deploy.merge(servers: { "web" => { "hosts" => nil } }) + end + + # One role with hosts, one without + assert_raises(ArgumentError) do + Mrsk::Configuration.new @deploy.merge(servers: { "web" => %w[ web ], "workers" => { "hosts" => %w[ ] } }) + end end test "ssh options" do @@ -163,17 +215,17 @@ class ConfigurationTest < ActiveSupport::TestCase end test "erb evaluation of yml config" do - config = Mrsk::Configuration.create_from Pathname.new(File.expand_path("fixtures/deploy.erb.yml", __dir__)) + config = Mrsk::Configuration.create_from config_file: Pathname.new(File.expand_path("fixtures/deploy.erb.yml", __dir__)) assert_equal "my-user", config.registry["username"] end test "destination yml config merge" do dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_for_dest.yml", __dir__)) - config = Mrsk::Configuration.create_from dest_config_file, destination: "world" + config = Mrsk::Configuration.create_from config_file: dest_config_file, destination: "world" assert_equal "1.1.1.1", config.all_hosts.first - config = Mrsk::Configuration.create_from dest_config_file, destination: "mars" + config = Mrsk::Configuration.create_from config_file: dest_config_file, destination: "mars" assert_equal "1.1.1.3", config.all_hosts.first end @@ -181,7 +233,7 @@ class ConfigurationTest < ActiveSupport::TestCase dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_for_dest.yml", __dir__)) assert_raises(RuntimeError) do - config = Mrsk::Configuration.create_from dest_config_file, destination: "missing" + config = Mrsk::Configuration.create_from config_file: dest_config_file, destination: "missing" end end diff --git a/test/fixtures/deploy_with_roles.yml b/test/fixtures/deploy_with_roles.yml index 345c6301..0e405241 100644 --- a/test/fixtures/deploy_with_roles.yml +++ b/test/fixtures/deploy_with_roles.yml @@ -5,8 +5,9 @@ servers: - 1.1.1.1 - 1.1.1.2 workers: - - 1.1.1.3 - - 1.1.1.4 + hosts: + - 1.1.1.3 + - 1.1.1.4 env: REDIS_URL: redis://x/y registry: