diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fe7363e..1d9d2008 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,15 @@ jobs: - "2.7" - "3.1" - "3.2" + gemfile: + - Gemfile + - gemfiles/rails_edge.gemfile continue-on-error: [false] - name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }} runs-on: ubuntu-latest continue-on-error: ${{ matrix.continue-on-error }} - + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index da7e99a5..134ecac0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -16,10 +16,6 @@ jobs: - name: Checkout uses: actions/checkout@v3 - - - name: Extract Version Number - id: extract_version - run: echo "::set-output name=version::${{ github.ref | replace('refs/tags/', '') }}" - name: Set up QEMU uses: docker/setup-qemu-action@v2 @@ -42,4 +38,4 @@ jobs: push: true tags: | ghcr.io/mrsked/mrsk:latest - ghcr.io/mrsked/mrsk:${{ steps.extract_version.outputs.version }} + ghcr.io/mrsked/mrsk:${{ github.ref_name }} diff --git a/.gitignore b/.gitignore index 1e13116a..6100dc73 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .byebug_history *.gem coverage/* -.DS_Store \ No newline at end of file +.DS_Store +gemfiles/*.lock diff --git a/Dockerfile b/Dockerfile index 98dd3c69..b43c60e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ # Use the official Ruby 3.2.0 Alpine image as the base image FROM ruby:3.2.0-alpine +# Install docker/buildx-bin +COPY --from=docker/buildx-bin /buildx /usr/libexec/docker/cli-plugins/docker-buildx + # Set the working directory to /mrsk WORKDIR /mrsk diff --git a/Gemfile b/Gemfile index 9c31a8f8..f015da37 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,3 @@ source 'https://rubygems.org' git_source(:github) { |repo| "https://github.com/#{repo}.git" } gemspec - -gem "debug" -gem "mocha" -gem "railties" diff --git a/README.md b/README.md index 7d4f69c5..e4ef3060 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ 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 + ## Installation Install MRSK globally with `gem install mrsk` or build a dockerized version: @@ -35,7 +37,7 @@ env: - RAILS_MASTER_KEY ``` -Then edit your `.env` file to add your registry password as `MRSK_REGISTRY_PASSWORD` (and your `RAILS_MASTER_KEY` for production with a Rails app). +Then edit your `.env` file to add your registry password as `MRSK_REGISTRY_PASSWORD` (and your `RAILS_MASTER_KEY` for production with a Rails app). Now you're ready to deploy to the servers: @@ -79,6 +81,16 @@ Docker Swarm is much simpler than Kubernetes, but it's still built on the same d Ultimately, there are a myriad of ways to deploy web apps, but this is the toolkit we're using at [37signals](https://37signals.com) to bring [HEY](https://www.hey.com) [home from the cloud](https://world.hey.com/dhh/why-we-re-leaving-the-cloud-654b47e0) without losing the advantages of modern containerization tooling. +## Running MRSK from Docker + +MRSK is packaged up in a Docker container similarly to [rails/docked](https://github.com/rails/docked). This will allow you to run MRSK (from your application directory) without having to install any dependencies other than Docker. Add the following alias to your profile configuration to make working with the container more convenient: + +```bash +alias mrsk="docker run -it --rm -v '${PWD}:/workdir' -v '${SSH_AUTH_SOCK}:/ssh-agent' -v /var/run/docker.sock:/var/run/docker.sock -e 'SSH_AUTH_SOCK=/ssh-agent' ghcr.io/mrsked/mrsk:latest" +``` + +Since MRSK uses SSH to establish a remote connection, it will need access to your SSH agent. The above command uses a volume mount to make it available inside the container and configures the SSH agent inside the container to make use of it. + ## Configuration ### Using .env file to load required environment variables @@ -111,9 +123,9 @@ If you need separate env variables for different destinations, you can set them #### Bitwarden as a secret store -If you are using open source secret store like bitwarden, you can create `.env.erb` as a template which looks up the secrets. +If you are using open source secret store like bitwarden, you can create `.env.erb` as a template which looks up the secrets. -You can store `SOME_SECRET` in a secure note in bitwarden vault. +You can store `SOME_SECRET` in a secure note in bitwarden vault. ``` $ bw list items --search SOME_SECRET | jq @@ -152,7 +164,7 @@ SOME_SECRET=<%= `bw get notes 123123123-1232-4224-222f-234234234234 --session #{ <% else raise ArgumentError, "session_token token missing" end %> ``` -Then everyone deploying the app can run `mrsk envify` and mrsk will generate `.env` +Then everyone deploying the app can run `mrsk envify` and mrsk will generate `.env` ### Using another registry than Docker Hub @@ -162,9 +174,9 @@ The default registry is Docker Hub, but you can change it using `registry/server ```yaml registry: server: registry.digitalocean.com - username: + username: - DOCKER_REGISTRY_TOKEN - password: + password: - DOCKER_REGISTRY_TOKEN ``` @@ -234,6 +246,12 @@ volumes: - "/local/path:/container/path" ``` +### MRSK env variables + +The following env variables are set when your container runs: + +`MRSK_CONTAINER_NAME` : this contains the current container name and version + ### Using different roles for servers If your application uses separate hosts for running jobs or other roles beyond the default web running, you can specify these hosts in a dedicated role with a new entrypoint command like so: @@ -268,12 +286,12 @@ servers: You can specialize the default Traefik rules by setting labels on the containers that are being started: -``` +```yaml labels: - traefik.http.routers.hey.rule: Host(\`app.hey.com\`) + traefik.http.routers.hey.rule: Host(`app.hey.com`) ``` -Note: The escaped backticks are needed to ensure the rule is passed in correctly and not treated as command substitution by Bash! +Note: The backticks are needed to ensure the rule is passed in correctly and not treated as command substitution by Bash! This allows you to run multiple applications on the same server sharing the same Traefik instance and port. See https://doc.traefik.io/traefik/routing/routers/#rule for a full list of available routing rules. diff --git a/gemfiles/rails_edge.gemfile b/gemfiles/rails_edge.gemfile new file mode 100644 index 00000000..34a16801 --- /dev/null +++ b/gemfiles/rails_edge.gemfile @@ -0,0 +1,9 @@ +source 'https://rubygems.org' +git_source(:github) { |repo| "https://github.com/#{repo}.git" } + +git "https://github.com/rails/rails.git" do + gem "railties" + gem "activesupport" +end + +gemspec path: "../" diff --git a/lib/mrsk/cli/accessory.rb b/lib/mrsk/cli/accessory.rb index d0037cd4..834c7dec 100644 --- a/lib/mrsk/cli/accessory.rb +++ b/lib/mrsk/cli/accessory.rb @@ -149,13 +149,13 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base end end - desc "remove [NAME]", "Remove accessory container and image from host (use NAME=all to remove all accessories)" + desc "remove [NAME]", "Remove accessory container, image and data directory from host (use NAME=all to remove all accessories)" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" def remove(name) if name == "all" MRSK.accessory_names.each { |accessory_name| remove(accessory_name) } else - if options[:confirmed] || ask("This will remove all containers and images for #{name}. Are you sure?", limited_to: %w( y N ), default: "N") == "y" + if options[:confirmed] || ask("This will remove all containers, images and data directories for #{name}. Are you sure?", limited_to: %w( y N ), default: "N") == "y" with_accessory(name) do stop(name) remove_container(name) diff --git a/lib/mrsk/cli/main.rb b/lib/mrsk/cli/main.rb index 73268f50..328af480 100644 --- a/lib/mrsk/cli/main.rb +++ b/lib/mrsk/cli/main.rb @@ -9,42 +9,58 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base end desc "deploy", "Deploy app to servers" + option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" def deploy + invoke_options = options.without(:skip_push) + runtime = print_runtime do say "Ensure curl and Docker are installed...", :magenta - invoke "mrsk:cli:server:bootstrap" + invoke "mrsk:cli:server:bootstrap", [], invoke_options say "Log into image registry...", :magenta - invoke "mrsk:cli:registry:login" + invoke "mrsk:cli:registry:login", [], invoke_options - say "Build and push app image...", :magenta - invoke "mrsk:cli:build:deliver" + if options[:skip_push] + say "Pull app image...", :magenta + invoke "mrsk:cli:build:pull", [], invoke_options + else + say "Build and push app image...", :magenta + invoke "mrsk:cli:build:deliver", [], invoke_options + end say "Ensure Traefik is running...", :magenta - invoke "mrsk:cli:traefik:boot" + invoke "mrsk:cli:traefik:boot", [], invoke_options say "Ensure app can pass healthcheck...", :magenta - invoke "mrsk:cli:healthcheck:perform" + invoke "mrsk:cli:healthcheck:perform", [], invoke_options - invoke "mrsk:cli:app:boot" + invoke "mrsk:cli:app:boot", [], invoke_options say "Prune old containers and images...", :magenta - invoke "mrsk:cli:prune:all" + invoke "mrsk:cli:prune:all", [], invoke_options end audit_broadcast "Deployed app in #{runtime.to_i} seconds" unless options[:skip_broadcast] end desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login" + option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" def redeploy + invoke_options = options.without(:skip_push) + runtime = print_runtime do - say "Build and push app image...", :magenta - invoke "mrsk:cli:build:deliver" + if options[:skip_push] + say "Pull app image...", :magenta + invoke "mrsk:cli:build:pull", [], invoke_options + else + say "Build and push app image...", :magenta + invoke "mrsk:cli:build:deliver", [], invoke_options + end say "Ensure app can pass healthcheck...", :magenta - invoke "mrsk:cli:healthcheck:perform" + invoke "mrsk:cli:healthcheck:perform", [], invoke_options - invoke "mrsk:cli:app:boot" + invoke "mrsk:cli:app:boot", [], invoke_options end audit_broadcast "Redeployed app in #{runtime.to_i} seconds" unless options[:skip_broadcast] @@ -119,8 +135,10 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base puts "Binstub already exists in bin/mrsk (remove first to create a new one)" else puts "Adding MRSK to Gemfile and bundle..." - `bundle add mrsk` - `bundle binstubs mrsk` + run_locally do + execute :bundle, :add, :mrsk + execute :bundle, :binstubs, :mrsk + end puts "Created binstub file in bin/mrsk" end end diff --git a/lib/mrsk/commands/app.rb b/lib/mrsk/commands/app.rb index 906a30d2..37d70476 100644 --- a/lib/mrsk/commands/app.rb +++ b/lib/mrsk/commands/app.rb @@ -7,6 +7,7 @@ class Mrsk::Commands::App < Mrsk::Commands::Base "--restart unless-stopped", "--log-opt", "max-size=#{MAX_LOG_SIZE}", "--name", service_with_version_and_destination, + "-e", "MRSK_CONTAINER_NAME=\"#{service_with_version_and_destination}\"", *role.env_args, *config.volume_args, *role.label_args, diff --git a/lib/mrsk/commands/healthcheck.rb b/lib/mrsk/commands/healthcheck.rb index 40ec2af7..ba952d15 100644 --- a/lib/mrsk/commands/healthcheck.rb +++ b/lib/mrsk/commands/healthcheck.rb @@ -9,8 +9,10 @@ class Mrsk::Commands::Healthcheck < Mrsk::Commands::Base "--name", container_name_with_version, "--publish", "#{EXPOSED_PORT}:#{config.healthcheck["port"]}", "--label", "service=#{container_name}", + "-e", "MRSK_CONTAINER_NAME=\"#{container_name}\"", *web.env_args, *config.volume_args, + *web.option_args, config.absolute_image, web.cmd end diff --git a/lib/mrsk/commands/traefik.rb b/lib/mrsk/commands/traefik.rb index ee22e64b..b9ff19c1 100644 --- a/lib/mrsk/commands/traefik.rb +++ b/lib/mrsk/commands/traefik.rb @@ -55,14 +55,14 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base private def cmd_option_args - if args = config.raw_config.dig(:traefik, "args") - optionize args + if args = config.traefik["args"] + optionize args, with: "=" else [] end end def host_port - config.raw_config.dig(:traefik, "host_port") || CONTAINER_PORT + config.traefik["host_port"] || CONTAINER_PORT end end diff --git a/lib/mrsk/configuration.rb b/lib/mrsk/configuration.rb index 64c116a2..03413116 100644 --- a/lib/mrsk/configuration.rb +++ b/lib/mrsk/configuration.rb @@ -165,6 +165,9 @@ class Mrsk::Configuration }.compact end + def traefik + raw_config.traefik || {} + end private # Will raise ArgumentError if any required config keys are missing diff --git a/lib/mrsk/configuration/role.rb b/lib/mrsk/configuration/role.rb index cc56be3d..f3e20e9a 100644 --- a/lib/mrsk/configuration/role.rb +++ b/lib/mrsk/configuration/role.rb @@ -73,8 +73,9 @@ class Mrsk::Configuration::Role "traefik.http.routers.#{config.service}.rule" => "PathPrefix(`/`)", "traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => config.healthcheck["path"], "traefik.http.services.#{config.service}.loadbalancer.healthcheck.interval" => "1s", - "traefik.http.middlewares.#{config.service}.retry.attempts" => "5", - "traefik.http.middlewares.#{config.service}.retry.initialinterval" => "500ms" + "traefik.http.middlewares.#{config.service}-retry.retry.attempts" => "5", + "traefik.http.middlewares.#{config.service}-retry.retry.initialinterval" => "500ms", + "traefik.http.routers.#{config.service}.middlewares" => "#{config.service}-retry@docker" } else {} diff --git a/lib/mrsk/utils.rb b/lib/mrsk/utils.rb index d8860f1a..1e763250 100644 --- a/lib/mrsk/utils.rb +++ b/lib/mrsk/utils.rb @@ -24,8 +24,14 @@ module Mrsk::Utils end # Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option. - def optionize(args) - args.collect { |(key, value)| [ "--#{key}", value == true ? nil : escape_shell_value(value) ] }.flatten.compact + def optionize(args, with: nil) + options = if with + 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) ] } + end + + options.flatten.compact end # Copied from SSHKit::Backend::Abstract#redact to be available inside Commands classes diff --git a/mrsk.gemspec b/mrsk.gemspec index 6cd77252..d359aa28 100644 --- a/mrsk.gemspec +++ b/mrsk.gemspec @@ -19,4 +19,8 @@ Gem::Specification.new do |spec| spec.add_dependency "zeitwerk", "~> 2.5" spec.add_dependency "ed25519", "~> 1.2" spec.add_dependency "bcrypt_pbkdf", "~> 1.0" + + spec.add_development_dependency "debug" + spec.add_development_dependency "mocha" + spec.add_development_dependency "railties" end diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 9f5640e4..f72741dd 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -1,9 +1,80 @@ require_relative "cli_test_case" class CliMainTest < CliTestCase - test "version" do - version = stdouted { Mrsk::Cli::Main.new.version } - assert_equal Mrsk::VERSION, version + test "setup" do + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:server:bootstrap") + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:accessory:boot", [ "all" ]) + Mrsk::Cli::Main.any_instance.expects(:deploy) + + run_command("setup") + end + + test "deploy" do + invoke_options = { "config_file" => "test/fixtures/deploy_with_accessories.yml", "skip_broadcast" => false, "skip_push" => false } + + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:server:bootstrap", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:registry:login", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:deliver", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:traefik:boot", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:healthcheck:perform", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:boot", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:prune:all", [], invoke_options) + + run_command("deploy").tap do |output| + assert_match /Ensure curl and Docker are installed/, output + assert_match /Log into image registry/, output + assert_match /Build and push app image/, output + assert_match /Ensure Traefik is running/, output + assert_match /Ensure app can pass healthcheck/, output + assert_match /Prune old containers and images/, output + end + end + + test "deploy with skip_push" do + invoke_options = { "config_file" => "test/fixtures/deploy_with_accessories.yml", "skip_broadcast" => false, "skip_push" => true } + + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:server:bootstrap", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:registry:login", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:pull", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:traefik:boot", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:healthcheck:perform", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:boot", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:prune:all", [], invoke_options) + + run_command("deploy", "--skip_push").tap do |output| + assert_match /Ensure curl and Docker are installed/, output + assert_match /Log into image registry/, output + assert_match /Pull app image/, output + assert_match /Ensure Traefik is running/, output + assert_match /Ensure app can pass healthcheck/, output + assert_match /Prune old containers and images/, output + end + end + + test "redeploy" do + invoke_options = { "config_file" => "test/fixtures/deploy_with_accessories.yml", "skip_broadcast" => false, "skip_push" => false} + + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:deliver", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:healthcheck:perform", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:boot", [], invoke_options) + + run_command("redeploy").tap do |output| + assert_match /Build and push app image/, output + assert_match /Ensure app can pass healthcheck/, output + end + end + + test "redeploy with skip_push" do + invoke_options = { "config_file" => "test/fixtures/deploy_with_accessories.yml", "skip_broadcast" => false, "skip_push" => true } + + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:pull", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:healthcheck:perform", [], invoke_options) + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:boot", [], invoke_options) + + run_command("redeploy", "--skip_push").tap do |output| + assert_match /Pull app image/, output + assert_match /Ensure app can pass healthcheck/, output + end end test "rollback bad version" do @@ -25,6 +96,95 @@ class CliMainTest < CliTestCase end end + test "details" do + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:traefik:details") + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:details") + Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:accessory:details", [ "all" ]) + + run_command("details") + end + + test "audit" do + run_command("audit").tap do |output| + assert_match /tail -n 50 mrsk-app-audit.log on 1.1.1.1/, output + assert_match /App Host: 1.1.1.1/, output + assert_match /tail -n 50 mrsk-app-audit.log on 1.1.1.2/, output + assert_match /App Host: 1.1.1.2/, output + end + end + + test "config" do + run_command("config").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 "dhh/app", config[:repository] + assert_equal "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) + FileUtils.stubs(:cp_r) + + run_command("init").tap do |output| + assert_match /Created configuration file in config\/deploy.yml/, output + assert_match /Created \.env file/, output + end + end + + test "init with existing config" do + Pathname.any_instance.expects(:exist?).returns(true).twice + + run_command("init").tap do |output| + assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output + end + end + + test "init with bundle option" do + Pathname.any_instance.expects(:exist?).returns(false).times(3) + FileUtils.stubs(:mkdir_p) + FileUtils.stubs(:cp_r) + + run_command("init", "--bundle").tap do |output| + assert_match /Created configuration file in config\/deploy.yml/, output + assert_match /Created \.env file/, output + assert_match /Adding MRSK to Gemfile and bundle/, output + assert_match /bundle add mrsk/, output + assert_match /bundle binstubs mrsk/, output + assert_match /Created binstub file in bin\/mrsk/, output + end + end + + test "init with bundle option and existing binstub" do + Pathname.any_instance.expects(:exist?).returns(true).times(3) + FileUtils.stubs(:mkdir_p) + FileUtils.stubs(:cp_r) + + run_command("init", "--bundle").tap do |output| + assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output + assert_match /Binstub already exists in bin\/mrsk \(remove first to create a new one\)/, output + end + end + + test "envify" do + File.expects(:read).with(".env.erb").returns("HELLO=<%= 'world' %>") + File.expects(:write).with(".env", "HELLO=world", perm: 0600) + + run_command("envify") + end + + test "envify with destination" do + File.expects(:read).with(".env.staging.erb").returns("HELLO=<%= 'world' %>") + File.expects(:write).with(".env.staging", "HELLO=world", perm: 0600) + + run_command("envify", "-d", "staging") + end + test "remove with confirmation" do run_command("remove", "-y").tap do |output| assert_match /docker container stop traefik/, output @@ -49,6 +209,11 @@ class CliMainTest < CliTestCase end end + test "version" do + version = stdouted { Mrsk::Cli::Main.new.version } + assert_equal Mrsk::VERSION, version + end + private def run_command(*command) stdouted { Mrsk::Cli::Main.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) } diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index da436bcb..c1a30f46 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -14,7 +14,7 @@ class CommandsAppTest < ActiveSupport::TestCase test "run" do assert_equal \ - "docker run --detach --restart unless-stopped --log-opt max-size=10m --name app-999 -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=\"5\" --label traefik.http.middlewares.app.retry.initialinterval=\"500ms\" dhh/app:999", + "docker run --detach --restart unless-stopped --log-opt max-size=10m --name app-999 -e MRSK_CONTAINER_NAME=\"app-999\" -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.retry.attempts=\"5\" --label traefik.http.middlewares.app-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app.middlewares=\"app-retry@docker\" dhh/app:999", @app.run.join(" ") end @@ -22,7 +22,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:volumes] = ["/local/path:/container/path" ] assert_equal \ - "docker run --detach --restart unless-stopped --log-opt max-size=10m --name app-999 -e RAILS_MASTER_KEY=\"456\" --volume /local/path:/container/path --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=\"5\" --label traefik.http.middlewares.app.retry.initialinterval=\"500ms\" dhh/app:999", + "docker run --detach --restart unless-stopped --log-opt max-size=10m --name app-999 -e MRSK_CONTAINER_NAME=\"app-999\" -e RAILS_MASTER_KEY=\"456\" --volume /local/path:/container/path --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.retry.attempts=\"5\" --label traefik.http.middlewares.app-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app.middlewares=\"app-retry@docker\" dhh/app:999", @app.run.join(" ") end @@ -30,7 +30,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:healthcheck] = { "path" => "/healthz" } assert_equal \ - "docker run --detach --restart unless-stopped --log-opt max-size=10m --name app-999 -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=\"/healthz\" --label traefik.http.services.app.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app.retry.attempts=\"5\" --label traefik.http.middlewares.app.retry.initialinterval=\"500ms\" dhh/app:999", + "docker run --detach --restart unless-stopped --log-opt max-size=10m --name app-999 -e MRSK_CONTAINER_NAME=\"app-999\" -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=\"/healthz\" --label traefik.http.services.app.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app.middlewares=\"app-retry@docker\" dhh/app:999", @app.run.join(" ") end @@ -39,7 +39,7 @@ class CommandsAppTest < ActiveSupport::TestCase @app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config).tap { |c| c.version = "999" } assert_equal \ - "docker run --detach --restart unless-stopped --log-opt max-size=10m --name app-999 -e RAILS_MASTER_KEY=\"456\" --label service=\"app\" --label role=\"jobs\" --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs", + "docker run --detach --restart unless-stopped --log-opt max-size=10m --name app-999 -e MRSK_CONTAINER_NAME=\"app-999\" -e RAILS_MASTER_KEY=\"456\" --label service=\"app\" --label role=\"jobs\" --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs", @app.run(role: :jobs).join(" ") end diff --git a/test/commands/healthcheck_test.rb b/test/commands/healthcheck_test.rb index 05fbc8c3..effd3ef3 100644 --- a/test/commands/healthcheck_test.rb +++ b/test/commands/healthcheck_test.rb @@ -10,7 +10,7 @@ class CommandsHealthcheckTest < ActiveSupport::TestCase test "run" do assert_equal \ - "docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app dhh/app:123", + "docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e MRSK_CONTAINER_NAME=\"healthcheck-app\" dhh/app:123", new_command.run.join(" ") end @@ -18,7 +18,7 @@ class CommandsHealthcheckTest < ActiveSupport::TestCase @config[:healthcheck] = { "port" => 3001 } assert_equal \ - "docker run --detach --name healthcheck-app-123 --publish 3999:3001 --label service=healthcheck-app dhh/app:123", + "docker run --detach --name healthcheck-app-123 --publish 3999:3001 --label service=healthcheck-app -e MRSK_CONTAINER_NAME=\"healthcheck-app\" dhh/app:123", new_command.run.join(" ") end @@ -26,7 +26,14 @@ class CommandsHealthcheckTest < ActiveSupport::TestCase @destination = "staging" assert_equal \ - "docker run --detach --name healthcheck-app-staging-123 --publish 3999:3000 --label service=healthcheck-app-staging dhh/app:123", + "docker run --detach --name healthcheck-app-staging-123 --publish 3999:3000 --label service=healthcheck-app-staging -e MRSK_CONTAINER_NAME=\"healthcheck-app-staging\" dhh/app:123", + new_command.run.join(" ") + end + + test "run with custom options" do + @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere" } } } + assert_equal \ + "docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e MRSK_CONTAINER_NAME=\"healthcheck-app\" --mount \"somewhere\" dhh/app:123", new_command.run.join(" ") end diff --git a/test/commands/traefik_test.rb b/test/commands/traefik_test.rb index 97310623..5ca51e13 100644 --- a/test/commands/traefik_test.rb +++ b/test/commands/traefik_test.rb @@ -4,18 +4,18 @@ class CommandsTraefikTest < ActiveSupport::TestCase setup do @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], - traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } } + traefik: { "args" => { "accesslog.format" => "json", "api.insecure" => true, "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } } } end test "run" 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\" --metrics.prometheus.buckets \"0.1,0.3,1.2,5.0\"", + "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]["host_port"] = "8080" assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --log-opt max-size=10m --publish 8080:80 --volume /var/run/docker.sock:/var/run/docker.sock traefik --providers.docker --log.level=DEBUG --accesslog.format \"json\" --metrics.prometheus.buckets \"0.1,0.3,1.2,5.0\"", + "docker run --name traefik --detach --restart unless-stopped --log-opt max-size=10m --publish 8080: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(" ") end diff --git a/test/configuration/role_test.rb b/test/configuration/role_test.rb index 1017221f..0f83d3df 100644 --- a/test/configuration/role_test.rb +++ b/test/configuration/role_test.rb @@ -42,7 +42,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase 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=\"5\"", "--label", "traefik.http.middlewares.app.retry.initialinterval=\"500ms\""], @config.role(:web).label_args + 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.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app.middlewares=\"app-retry@docker\"" ], @config.role(:web).label_args end test "custom labels" do @@ -66,7 +66,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase c[:servers]["beta"] = { "traefik" => "true", "hosts" => [ "1.1.1.5" ] } }) - assert_equal [ "--label", "service=\"app\"", "--label", "role=\"beta\"", "--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=\"5\"", "--label", "traefik.http.middlewares.app.retry.initialinterval=\"500ms\"" ], config.role(:beta).label_args + assert_equal [ "--label", "service=\"app\"", "--label", "role=\"beta\"", "--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.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app.middlewares=\"app-retry@docker\"" ], config.role(:beta).label_args end test "env overwritten by role" do diff --git a/test/test_helper.rb b/test/test_helper.rb index 4c381df2..3704e8e2 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -9,7 +9,18 @@ require "mrsk" ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"] +# Applies to remote commands only. SSHKit.config.backend = SSHKit::Backend::Printer +# Ensure local commands use the printer backend too. +# See https://github.com/capistrano/sshkit/blob/master/lib/sshkit/dsl.rb#L9 +module SSHKit + module DSL + def run_locally(&block) + SSHKit::Backend::Printer.new(SSHKit::Host.new(:local), &block).run + end + end +end + class ActiveSupport::TestCase end