Skip to content

Commit

Permalink
Add aliases to Kamal
Browse files Browse the repository at this point in the history
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!
  • Loading branch information
djmb committed Aug 26, 2024
1 parent f48987a commit 6e5fc95
Show file tree
Hide file tree
Showing 19 changed files with 207 additions and 49 deletions.
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 11 additions & 9 deletions bin/docs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ end

DOCS = {
"accessory" => "Accessories",
"alias" => "Aliases",
"boot" => "Booting",
"builder" => "Builders",
"configuration" => "Configuration overview",
Expand Down Expand Up @@ -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
Expand All @@ -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]*)(.*)/
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion kamal.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions lib/kamal/cli/alias/command.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 10 additions & 3 deletions lib/kamal/cli/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion lib/kamal/commander.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/kamal/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 || {})
Expand Down
15 changes: 15 additions & 0 deletions lib/kamal/configuration/alias.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 26 additions & 0 deletions lib/kamal/configuration/docs/alias.yml
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 6 additions & 0 deletions lib/kamal/configuration/docs/configuration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,9 @@ healthcheck:
# Docker logging configuration, see kamal docs logging
logging:
...

# Aliases
#
# Alias configuration, see kamal docs alias
aliases:
...
50 changes: 26 additions & 24 deletions lib/kamal/configuration/validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions lib/kamal/configuration/validator/alias.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions test/cli/cli_test_case.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
33 changes: 32 additions & 1 deletion test/cli/main_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions test/fixtures/deploy_with_aliases.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 0 additions & 4 deletions test/integration/docker/deployer/app/.kamal/hooks/pre-connect
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit 6e5fc95

Please sign in to comment.