Add aliases to Kamal
Aliases are defined in the configuration file under the `aliases` key. The configuration is a map of alias name to command. When we run the command the we just do a literal replacement of the alias with the string. So if we have: ```yaml aliases: console: app exec -r console -i --reuse "rails console" ``` Then running `kamal console -r workers` will run the command ```sh $ kamal app exec -r console -i --reuse "rails console" -r workers ``` Because of the order Thor parses the arguments, this allows us to override the role from the alias command. There might be cases where we need to munge the command a bit more but that would involve getting into Thor command parsing internals, which are complicated and possibly subject to change. There's a chance that your aliases could conflict with future built-in commands, but there's not likely to be many of those and if it happens you'll get a validation error when you upgrade. Thanks to @dhnaranjo for the idea!
This commit is contained in:
committed by
Donal McBreen
parent
f48987aa03
commit
b8af719bb7
@@ -10,7 +10,7 @@ PATH
|
|||||||
ed25519 (~> 1.2)
|
ed25519 (~> 1.2)
|
||||||
net-ssh (~> 7.0)
|
net-ssh (~> 7.0)
|
||||||
sshkit (>= 1.23.0, < 2.0)
|
sshkit (>= 1.23.0, < 2.0)
|
||||||
thor (~> 1.2)
|
thor (~> 1.3)
|
||||||
zeitwerk (~> 2.5)
|
zeitwerk (~> 2.5)
|
||||||
|
|
||||||
GEM
|
GEM
|
||||||
|
|||||||
20
bin/docs
20
bin/docs
@@ -17,6 +17,7 @@ end
|
|||||||
|
|
||||||
DOCS = {
|
DOCS = {
|
||||||
"accessory" => "Accessories",
|
"accessory" => "Accessories",
|
||||||
|
"alias" => "Aliases",
|
||||||
"boot" => "Booting",
|
"boot" => "Booting",
|
||||||
"builder" => "Builders",
|
"builder" => "Builders",
|
||||||
"configuration" => "Configuration overview",
|
"configuration" => "Configuration overview",
|
||||||
@@ -67,26 +68,27 @@ class DocWriter
|
|||||||
output.puts
|
output.puts
|
||||||
place = :new_section
|
place = :new_section
|
||||||
elsif line =~ /^ *#/
|
elsif line =~ /^ *#/
|
||||||
generate_line(line, place: place)
|
generate_line(line, heading: place == :new_section)
|
||||||
place = :in_section
|
place = :in_section
|
||||||
else
|
else
|
||||||
output.puts "```yaml"
|
output.puts "```yaml"
|
||||||
output.print line
|
output.puts line
|
||||||
place = :in_yaml
|
place = :in_yaml
|
||||||
end
|
end
|
||||||
when :in_yaml
|
when :in_yaml, :in_empty_line_yaml
|
||||||
if line =~ /^ *#/
|
if line =~ /^ *#/
|
||||||
output.puts "```"
|
output.puts "```"
|
||||||
generate_line(line, place: :new_section)
|
generate_line(line, heading: place == :in_empty_line_yaml)
|
||||||
place = :in_section
|
place = :in_section
|
||||||
|
elsif line.empty?
|
||||||
|
place = :in_empty_line_yaml
|
||||||
else
|
else
|
||||||
output.puts
|
output.puts line
|
||||||
output.print line
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
output.puts "\n```" if place == :in_yaml
|
output.puts "```" if place == :in_yaml
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_header
|
def generate_header
|
||||||
@@ -98,7 +100,7 @@ class DocWriter
|
|||||||
output.puts
|
output.puts
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_line(line, place: :in_section)
|
def generate_line(line, heading: false)
|
||||||
line = line.gsub(/^ *#\s?/, "")
|
line = line.gsub(/^ *#\s?/, "")
|
||||||
|
|
||||||
if line =~ /(.*)kamal docs ([a-z]*)(.*)/
|
if line =~ /(.*)kamal docs ([a-z]*)(.*)/
|
||||||
@@ -109,7 +111,7 @@ class DocWriter
|
|||||||
line = "#{$1}[#{titlify($2.split("/").last)}](#{$2})#{$3}"
|
line = "#{$1}[#{titlify($2.split("/").last)}](#{$2})#{$3}"
|
||||||
end
|
end
|
||||||
|
|
||||||
if place == :new_section
|
if heading
|
||||||
output.puts "## [#{line}](##{linkify(line)})"
|
output.puts "## [#{line}](##{linkify(line)})"
|
||||||
else
|
else
|
||||||
output.puts line
|
output.puts line
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Gem::Specification.new do |spec|
|
|||||||
spec.add_dependency "activesupport", ">= 7.0"
|
spec.add_dependency "activesupport", ">= 7.0"
|
||||||
spec.add_dependency "sshkit", ">= 1.23.0", "< 2.0"
|
spec.add_dependency "sshkit", ">= 1.23.0", "< 2.0"
|
||||||
spec.add_dependency "net-ssh", "~> 7.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 "dotenv", "~> 2.8"
|
||||||
spec.add_dependency "zeitwerk", "~> 2.5"
|
spec.add_dependency "zeitwerk", "~> 2.5"
|
||||||
spec.add_dependency "ed25519", "~> 1.2"
|
spec.add_dependency "ed25519", "~> 1.2"
|
||||||
|
|||||||
9
lib/kamal/cli/alias/command.rb
Normal file
9
lib/kamal/cli/alias/command.rb
Normal file
@@ -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
|
||||||
@@ -6,7 +6,8 @@ module Kamal::Cli
|
|||||||
class Base < Thor
|
class Base < Thor
|
||||||
include SSHKit::DSL
|
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 :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging"
|
||||||
class_option :quiet, type: :boolean, aliases: "-q", desc: "Minimal logging"
|
class_option :quiet, type: :boolean, aliases: "-q", desc: "Minimal logging"
|
||||||
@@ -22,8 +23,14 @@ module Kamal::Cli
|
|||||||
|
|
||||||
class_option :skip_hooks, aliases: "-H", type: :boolean, default: false, desc: "Don't run hooks"
|
class_option :skip_hooks, aliases: "-H", type: :boolean, default: false, desc: "Don't run hooks"
|
||||||
|
|
||||||
def initialize(*)
|
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, it means the arguments are passed in args rather than local_options.
|
||||||
|
super([], args, config)
|
||||||
|
else
|
||||||
super
|
super
|
||||||
|
end
|
||||||
@original_env = ENV.to_h.dup
|
@original_env = ENV.to_h.dup
|
||||||
load_env
|
load_env
|
||||||
initialize_commander(options_with_subcommand_class_options)
|
initialize_commander(options_with_subcommand_class_options)
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ class Kamal::Commander
|
|||||||
|
|
||||||
def specific_primary!
|
def specific_primary!
|
||||||
@specifics = nil
|
@specifics = nil
|
||||||
|
if specific_roles.present?
|
||||||
|
self.specific_hosts = [ specific_roles.first.primary_host ]
|
||||||
|
else
|
||||||
self.specific_hosts = [ config.primary_host ]
|
self.specific_hosts = [ config.primary_host ]
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def specific_roles=(role_names)
|
def specific_roles=(role_names)
|
||||||
@specifics = nil
|
@specifics = nil
|
||||||
@@ -113,6 +117,10 @@ class Kamal::Commander
|
|||||||
@traefik ||= Kamal::Commands::Traefik.new(config)
|
@traefik ||= Kamal::Commands::Traefik.new(config)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def alias(name)
|
||||||
|
config.aliases[name]
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
def with_verbosity(level)
|
def with_verbosity(level)
|
||||||
old_level = self.verbosity
|
old_level = self.verbosity
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class Kamal::Configuration
|
|||||||
delegate :argumentize, :optionize, to: Kamal::Utils
|
delegate :argumentize, :optionize, to: Kamal::Utils
|
||||||
|
|
||||||
attr_reader :destination, :raw_config
|
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
|
include Validation
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ class Kamal::Configuration
|
|||||||
@registry = Registry.new(config: self)
|
@registry = Registry.new(config: self)
|
||||||
|
|
||||||
@accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, 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)
|
@boot = Boot.new(config: self)
|
||||||
@builder = Builder.new(config: self)
|
@builder = Builder.new(config: self)
|
||||||
@env = Env.new(config: @raw_config.env || {})
|
@env = Env.new(config: @raw_config.env || {})
|
||||||
|
|||||||
15
lib/kamal/configuration/alias.rb
Normal file
15
lib/kamal/configuration/alias.rb
Normal file
@@ -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
|
||||||
26
lib/kamal/configuration/docs/alias.yml
Normal file
26
lib/kamal/configuration/docs/alias.yml
Normal file
@@ -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"
|
||||||
@@ -166,3 +166,9 @@ healthcheck:
|
|||||||
# Docker logging configuration, see kamal docs logging
|
# Docker logging configuration, see kamal docs logging
|
||||||
logging:
|
logging:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
# Aliases
|
||||||
|
#
|
||||||
|
# Alias configuration, see kamal docs alias
|
||||||
|
aliases:
|
||||||
|
...
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ class Kamal::Configuration::Validator
|
|||||||
|
|
||||||
private
|
private
|
||||||
def validate_against_example!(validation_config, example)
|
def validate_against_example!(validation_config, example)
|
||||||
validate_type! validation_config, Hash
|
validate_type! validation_config, example.class
|
||||||
|
|
||||||
|
if example.class == Hash
|
||||||
check_unknown_keys! validation_config, example
|
check_unknown_keys! validation_config, example
|
||||||
|
|
||||||
validation_config.each do |key, value|
|
validation_config.each do |key, value|
|
||||||
@@ -43,6 +44,7 @@ class Kamal::Configuration::Validator
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
def valid_type?(value, type)
|
def valid_type?(value, type)
|
||||||
|
|||||||
15
lib/kamal/configuration/validator/alias.rb
Normal file
15
lib/kamal/configuration/validator/alias.rb
Normal file
@@ -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
|
||||||
@@ -63,4 +63,12 @@ class CliTestCase < ActiveSupport::TestCase
|
|||||||
|
|
||||||
assert_match expected, output
|
assert_match expected, output
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def with_argv(*argv)
|
||||||
|
old_argv = ARGV
|
||||||
|
ARGV.replace(*argv)
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
ARGV.replace(old_argv)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -537,9 +537,40 @@ class CliMainTest < CliTestCase
|
|||||||
assert_equal Kamal::VERSION, version
|
assert_equal Kamal::VERSION, version
|
||||||
end
|
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
|
private
|
||||||
def run_command(*command, config_file: "deploy_simple")
|
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
|
end
|
||||||
|
|
||||||
def with_test_dotenv(**files)
|
def with_test_dotenv(**files)
|
||||||
|
|||||||
20
test/fixtures/deploy_with_aliases.yml
vendored
Normal file
20
test/fixtures/deploy_with_aliases.yml
vendored
Normal file
@@ -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
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
echo "About to lock..."
|
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
|
mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-connect
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
echo "About to lock..."
|
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
|
mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-connect
|
||||||
|
|||||||
@@ -37,3 +37,6 @@ accessories:
|
|||||||
- web
|
- web
|
||||||
stop_wait_time: 1
|
stop_wait_time: 1
|
||||||
readiness_delay: 0
|
readiness_delay: 0
|
||||||
|
aliases:
|
||||||
|
whome: version
|
||||||
|
worker_hostname: app exec -r workers -q --reuse hostname
|
||||||
|
|||||||
@@ -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])
|
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
|
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
|
test "setup and remove" do
|
||||||
# Check remove completes when nothing has been setup yet
|
# Check remove completes when nothing has been setup yet
|
||||||
kamal :remove, "-y"
|
kamal :remove, "-y"
|
||||||
|
|||||||
Reference in New Issue
Block a user