diff --git a/Gemfile.lock b/Gemfile.lock index a24796bda..6d553e8c2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,7 +10,7 @@ PATH ed25519 (~> 1.2) net-ssh (~> 7.0) sshkit (>= 1.23.0, < 2.0) - thor (~> 1.2) + thor (~> 1.3) zeitwerk (~> 2.5) GEM diff --git a/bin/docs b/bin/docs index 08b2937a7..a8731ce27 100755 --- a/bin/docs +++ b/bin/docs @@ -17,6 +17,7 @@ end DOCS = { "accessory" => "Accessories", + "alias" => "Aliases", "boot" => "Booting", "builder" => "Builders", "configuration" => "Configuration overview", @@ -67,26 +68,27 @@ class DocWriter output.puts place = :new_section elsif line =~ /^ *#/ - generate_line(line, place: place) + generate_line(line, heading: place == :new_section) place = :in_section else output.puts "```yaml" - output.print line + output.puts line place = :in_yaml end - when :in_yaml + when :in_yaml, :in_empty_line_yaml if line =~ /^ *#/ output.puts "```" - generate_line(line, place: :new_section) + generate_line(line, heading: place == :in_empty_line_yaml) place = :in_section + elsif line.empty? + place = :in_empty_line_yaml else - output.puts - output.print line + output.puts line end end end - output.puts "\n```" if place == :in_yaml + output.puts "```" if place == :in_yaml end def generate_header @@ -98,7 +100,7 @@ class DocWriter output.puts end - def generate_line(line, place: :in_section) + def generate_line(line, heading: false) line = line.gsub(/^ *#\s?/, "") if line =~ /(.*)kamal docs ([a-z]*)(.*)/ @@ -109,7 +111,7 @@ class DocWriter line = "#{$1}[#{titlify($2.split("/").last)}](#{$2})#{$3}" end - if place == :new_section + if heading output.puts "## [#{line}](##{linkify(line)})" else output.puts line diff --git a/kamal.gemspec b/kamal.gemspec index 3b20e8455..ff499f4e0 100644 --- a/kamal.gemspec +++ b/kamal.gemspec @@ -14,7 +14,7 @@ Gem::Specification.new do |spec| spec.add_dependency "activesupport", ">= 7.0" spec.add_dependency "sshkit", ">= 1.23.0", "< 2.0" spec.add_dependency "net-ssh", "~> 7.0" - spec.add_dependency "thor", "~> 1.2" + spec.add_dependency "thor", "~> 1.3" spec.add_dependency "dotenv", "~> 2.8" spec.add_dependency "zeitwerk", "~> 2.5" spec.add_dependency "ed25519", "~> 1.2" diff --git a/lib/kamal/cli/alias/command.rb b/lib/kamal/cli/alias/command.rb new file mode 100644 index 000000000..4bb70c5ae --- /dev/null +++ b/lib/kamal/cli/alias/command.rb @@ -0,0 +1,9 @@ +class Kamal::Cli::Alias::Command < Thor::DynamicCommand + def run(instance, args = []) + if (_alias = KAMAL.config.aliases[name]) + Kamal::Cli::Main.start(Shellwords.split(_alias.command) + ARGV[1..-1]) + else + super + end + end +end diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index 0710c0881..3a9ce0e10 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -6,7 +6,8 @@ module Kamal::Cli class Base < Thor include SSHKit::DSL - def self.exit_on_failure?() true end + def self.exit_on_failure?() false end + def self.dynamic_command_class() Kamal::Cli::Alias::Command end class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging" class_option :quiet, type: :boolean, aliases: "-q", desc: "Minimal logging" @@ -22,8 +23,14 @@ def self.exit_on_failure?() true end class_option :skip_hooks, aliases: "-H", type: :boolean, default: false, desc: "Don't run hooks" - def initialize(*) - super + def initialize(args = [], local_options = {}, config = {}) + if config[:current_command].is_a?(Kamal::Cli::Alias::Command) + # When Thor generates a dynamic command, it doesn't attempt to parse the arguments. + # For our purposes, we it means the arguments are passed in args rather than local_options. + super([], args, config) + else + super + end @original_env = ENV.to_h.dup load_env initialize_commander(options_with_subcommand_class_options) diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index c28fda82b..ae98e0f88 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -27,7 +27,11 @@ def configure(**kwargs) def specific_primary! @specifics = nil - self.specific_hosts = [ config.primary_host ] + if specific_roles.present? + self.specific_hosts = [ specific_roles.first.primary_host ] + else + self.specific_hosts = [ config.primary_host ] + end end def specific_roles=(role_names) @@ -113,6 +117,10 @@ def traefik @traefik ||= Kamal::Commands::Traefik.new(config) end + def alias(name) + config.aliases[name] + end + def with_verbosity(level) old_level = self.verbosity diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 8d989464b..af3754eb3 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -11,7 +11,7 @@ class Kamal::Configuration delegate :argumentize, :optionize, to: Kamal::Utils attr_reader :destination, :raw_config - attr_reader :accessories, :boot, :builder, :env, :healthcheck, :logging, :traefik, :servers, :ssh, :sshkit, :registry + attr_reader :accessories, :aliases, :boot, :builder, :env, :healthcheck, :logging, :traefik, :servers, :ssh, :sshkit, :registry include Validation @@ -54,6 +54,7 @@ def initialize(raw_config, destination: nil, version: nil, validate: true) @registry = Registry.new(config: self) @accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, config: self) } || [] + @aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {} @boot = Boot.new(config: self) @builder = Builder.new(config: self) @env = Env.new(config: @raw_config.env || {}) diff --git a/lib/kamal/configuration/alias.rb b/lib/kamal/configuration/alias.rb new file mode 100644 index 000000000..327578d91 --- /dev/null +++ b/lib/kamal/configuration/alias.rb @@ -0,0 +1,15 @@ +class Kamal::Configuration::Alias + include Kamal::Configuration::Validation + + attr_reader :name, :command + + def initialize(name, config:) + @name, @command = name.inquiry, config.raw_config["aliases"][name] + + validate! \ + command, + example: validation_yml["aliases"]["uname"], + context: "aliases/#{name}", + with: Kamal::Configuration::Validator::Alias + end +end diff --git a/lib/kamal/configuration/docs/alias.yml b/lib/kamal/configuration/docs/alias.yml new file mode 100644 index 000000000..4c28cfe3e --- /dev/null +++ b/lib/kamal/configuration/docs/alias.yml @@ -0,0 +1,26 @@ +# Aliases +# +# Aliases are shortcuts for Kamal commands. +# +# For example, for a Rails app, you might open a console with: +# +# ```shell +# kamal app exec -i -r console "rails console" +# ``` +# +# By defining an alias, like this: +aliases: + console: app exec -r console -i "rails console" +# You can now open the console with: +# ```shell +# kamal console +# ``` + +# Configuring aliases +# +# Aliases are defined in the root config under the alias key +# +# Each alias is named and can only contain lowercase letters, numbers, dashes and underscores. + +aliases: + uname: app exec -p -q -r web "uname -a" diff --git a/lib/kamal/configuration/docs/configuration.yml b/lib/kamal/configuration/docs/configuration.yml index fc9245c5a..f1045dd69 100644 --- a/lib/kamal/configuration/docs/configuration.yml +++ b/lib/kamal/configuration/docs/configuration.yml @@ -166,3 +166,9 @@ healthcheck: # Docker logging configuration, see kamal docs logging logging: ... + +# Aliases +# +# Alias configuration, see kamal docs alias +aliases: + ... diff --git a/lib/kamal/configuration/validator.rb b/lib/kamal/configuration/validator.rb index c7ba0f727..3374e0045 100644 --- a/lib/kamal/configuration/validator.rb +++ b/lib/kamal/configuration/validator.rb @@ -13,32 +13,34 @@ def validate! private def validate_against_example!(validation_config, example) - validate_type! validation_config, Hash - - check_unknown_keys! validation_config, example - - validation_config.each do |key, value| - next if extension?(key) - with_context(key) do - example_value = example[key] - - if example_value == "..." - validate_type! value, *(Array if key == :servers), Hash - elsif key == "hosts" - validate_servers! value - elsif example_value.is_a?(Array) - validate_array_of! value, example_value.first.class - elsif example_value.is_a?(Hash) - case key.to_s - when "options", "args" - validate_type! value, Hash - when "labels" - validate_hash_of! value, example_value.first[1].class + validate_type! validation_config, example.class + + if example.class == Hash + check_unknown_keys! validation_config, example + + validation_config.each do |key, value| + next if extension?(key) + with_context(key) do + example_value = example[key] + + if example_value == "..." + validate_type! value, *(Array if key == :servers), Hash + elsif key == "hosts" + validate_servers! value + elsif example_value.is_a?(Array) + validate_array_of! value, example_value.first.class + elsif example_value.is_a?(Hash) + case key.to_s + when "options", "args" + validate_type! value, Hash + when "labels" + validate_hash_of! value, example_value.first[1].class + else + validate_against_example! value, example_value + end else - validate_against_example! value, example_value + validate_type! value, example_value.class end - else - validate_type! value, example_value.class end end end diff --git a/lib/kamal/configuration/validator/alias.rb b/lib/kamal/configuration/validator/alias.rb new file mode 100644 index 000000000..5873d6e79 --- /dev/null +++ b/lib/kamal/configuration/validator/alias.rb @@ -0,0 +1,15 @@ +class Kamal::Configuration::Validator::Alias < Kamal::Configuration::Validator + def validate! + super + + name = context.delete_prefix("aliases/") + + if name !~ /\A[a-z0-9_-]+\z/ + error "Invalid alias name: '#{name}'. Must only contain lowercase letters, alphanumeric, hyphens and underscores." + end + + if Kamal::Cli::Main.commands.include?(name) + error "Alias '#{name}' conflicts with a built-in command." + end + end +end diff --git a/test/cli/cli_test_case.rb b/test/cli/cli_test_case.rb index 4c6b491d4..231da3d08 100644 --- a/test/cli/cli_test_case.rb +++ b/test/cli/cli_test_case.rb @@ -63,4 +63,12 @@ def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, assert_match expected, output end + + def with_argv(*argv) + old_argv = ARGV + ARGV.replace(*argv) + yield + ensure + ARGV.replace(old_argv) + end end diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index e562499b2..fa0ed553f 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -537,9 +537,40 @@ class CliMainTest < CliTestCase assert_equal Kamal::VERSION, version end + test "run an alias for details" do + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:details") + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:details") + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:details", [ "all" ]) + + run_command("info", config_file: "deploy_with_aliases") + end + + test "run an alias for a console" do + run_command("console", config_file: "deploy_with_aliases").tap do |output| + assert_match "docker exec app-console-999 bin/console on 1.1.1.5", output + assert_match "App Host: 1.1.1.5", output + end + end + + test "run an alias for a console overriding role" do + run_command("console", "-r", "workers", config_file: "deploy_with_aliases").tap do |output| + assert_match "docker exec app-workers-999 bin/console on 1.1.1.3", output + assert_match "App Host: 1.1.1.3", output + end + end + + test "run an alias for a console passing command" do + run_command("exec", "bin/job", config_file: "deploy_with_aliases").tap do |output| + assert_match "docker exec app-console-999 bin/job on 1.1.1.5", output + assert_match "App Host: 1.1.1.5", output + end + end + private def run_command(*command, config_file: "deploy_simple") - stdouted { Kamal::Cli::Main.start([ *command, "-c", "test/fixtures/#{config_file}.yml" ]) } + with_argv([ *command, "-c", "test/fixtures/#{config_file}.yml" ]) do + stdouted { Kamal::Cli::Main.start } + end end def with_test_dotenv(**files) diff --git a/test/fixtures/deploy_with_aliases.yml b/test/fixtures/deploy_with_aliases.yml new file mode 100644 index 000000000..6a8f00b81 --- /dev/null +++ b/test/fixtures/deploy_with_aliases.yml @@ -0,0 +1,20 @@ +service: app +image: dhh/app +servers: + web: + - 1.1.1.1 + - 1.1.1.2 + workers: + hosts: + - 1.1.1.3 + - 1.1.1.4 + console: + hosts: + - 1.1.1.5 +registry: + username: user + password: pw +aliases: + info: details + console: app exec --reuse -p -r console "bin/console" + exec: app exec --reuse -p -r console diff --git a/test/integration/docker/deployer/app/.kamal/hooks/pre-connect b/test/integration/docker/deployer/app/.kamal/hooks/pre-connect index e6c573d4b..e17784a59 100755 --- a/test/integration/docker/deployer/app/.kamal/hooks/pre-connect +++ b/test/integration/docker/deployer/app/.kamal/hooks/pre-connect @@ -1,8 +1,4 @@ #!/bin/sh echo "About to lock..." -if [ "$KAMAL_HOSTS" != "vm1,vm2" ]; then - echo "Expected hosts to be 'vm1,vm2', got $KAMAL_HOSTS" - exit 1 -fi mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-connect diff --git a/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-connect b/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-connect index 10095286a..e17784a59 100755 --- a/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-connect +++ b/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-connect @@ -1,8 +1,4 @@ #!/bin/sh echo "About to lock..." -if [ "$KAMAL_HOSTS" != "vm1,vm2,vm3" ]; then - echo "Expected hosts to be 'vm1,vm2,vm3', got $KAMAL_HOSTS" - exit 1 -fi mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-connect diff --git a/test/integration/docker/deployer/app_with_roles/config/deploy.yml b/test/integration/docker/deployer/app_with_roles/config/deploy.yml index 2cf362c66..e5c6e28ab 100644 --- a/test/integration/docker/deployer/app_with_roles/config/deploy.yml +++ b/test/integration/docker/deployer/app_with_roles/config/deploy.yml @@ -37,3 +37,6 @@ accessories: - web stop_wait_time: 1 readiness_delay: 0 +aliases: + whome: version + worker_hostname: app exec -r workers -q --reuse hostname diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index c4558c1d3..8ea04b110 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -82,6 +82,19 @@ class MainTest < IntegrationTest assert_equal({ "cmd"=>"wget -qO- http://localhost > /dev/null || exit 1", "interval"=>"1s", "max_attempts"=>3, "port"=>3000, "path"=>"/up", "cord"=>"/tmp/kamal-cord", "log_lines"=>50 }, config[:healthcheck]) end + test "aliases" do + @app = "app_with_roles" + + kamal :envify + kamal :deploy + + output = kamal :whome, capture: true + assert_equal Kamal::VERSION, output + + output = kamal :worker_hostname, capture: true + assert_match /App Host: vm3\nvm3-[0-9a-f]{12}$/, output + end + test "setup and remove" do # Check remove completes when nothing has been setup yet kamal :remove, "-y"