Skip to content

Commit ca96ce8

Browse files
committed
Support external commands in .kamal/bin and on PATH
Unknown commands are resolved as external executables before falling through to aliases or the error handler. Lookup order: 1. .kamal/bin/<name> (project-local) 2. kamal-<name> on PATH (system-wide) Builtin commands and unambiguous prefix matches are never shadowed. The external command is exec'd with remaining argv, replacing the kamal process.
1 parent 3bcd107 commit ca96ce8

File tree

2 files changed

+186
-0
lines changed

2 files changed

+186
-0
lines changed

lib/kamal/cli/main.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,28 @@
11
class Kamal::Cli::Main < Kamal::Cli::Base
2+
def self.dispatch(command, given_args, given_opts, config)
3+
if command.nil? && (ext = resolve_external_command(given_args.first))
4+
exec ext, *given_args.drop(1)
5+
else
6+
super
7+
end
8+
end
9+
10+
def self.resolve_external_command(name)
11+
return if name.nil? || name.start_with?("-") || \
12+
name.include?("/") || find_command_possibilities(name).any?
13+
14+
local = File.join(".kamal", "bin", name)
15+
return local if File.file?(local) && File.executable?(local)
16+
17+
ENV["PATH"]&.split(File::PATH_SEPARATOR)&.each do |dir|
18+
candidate = File.join(dir, "kamal-#{name}")
19+
return candidate if File.file?(candidate) && File.executable?(candidate)
20+
end
21+
22+
nil
23+
end
24+
private_class_method :resolve_external_command
25+
226
desc "setup", "Setup all accessories, push the env, and deploy app to servers"
327
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
428
option :no_cache, type: :boolean, default: false, desc: "Build without using Docker's build cache"

test/cli/main_test.rb

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,168 @@ class CliMainTest < CliTestCase
623623
end
624624
end
625625

626+
test "external command from local .kamal/bin" do
627+
Dir.mktmpdir do |tmpdir|
628+
Dir.chdir(tmpdir) do
629+
FileUtils.mkdir_p(".kamal/bin")
630+
File.write(".kamal/bin/foo", "#!/bin/sh\n")
631+
File.chmod(0755, ".kamal/bin/foo")
632+
633+
Kamal::Cli::Main.expects(:exec).with(".kamal/bin/foo")
634+
with_argv([ "foo" ]) { Kamal::Cli::Main.start }
635+
end
636+
end
637+
end
638+
639+
test "external command from PATH" do
640+
Dir.mktmpdir do |tmpdir|
641+
Dir.chdir(tmpdir) do
642+
bin_dir = File.join(tmpdir, "bin")
643+
FileUtils.mkdir_p(bin_dir)
644+
File.write(File.join(bin_dir, "kamal-bar"), "#!/bin/sh\n")
645+
File.chmod(0755, File.join(bin_dir, "kamal-bar"))
646+
647+
original_path = ENV["PATH"]
648+
ENV["PATH"] = "#{bin_dir}#{File::PATH_SEPARATOR}#{original_path}"
649+
650+
Kamal::Cli::Main.expects(:exec).with(File.join(bin_dir, "kamal-bar"))
651+
with_argv([ "bar" ]) { Kamal::Cli::Main.start }
652+
ensure
653+
ENV["PATH"] = original_path
654+
end
655+
end
656+
end
657+
658+
test "local external command takes priority over PATH" do
659+
Dir.mktmpdir do |tmpdir|
660+
Dir.chdir(tmpdir) do
661+
FileUtils.mkdir_p(".kamal/bin")
662+
File.write(".kamal/bin/baz", "#!/bin/sh\n")
663+
File.chmod(0755, ".kamal/bin/baz")
664+
665+
bin_dir = File.join(tmpdir, "path_bin")
666+
FileUtils.mkdir_p(bin_dir)
667+
File.write(File.join(bin_dir, "kamal-baz"), "#!/bin/sh\n")
668+
File.chmod(0755, File.join(bin_dir, "kamal-baz"))
669+
670+
original_path = ENV["PATH"]
671+
ENV["PATH"] = "#{bin_dir}#{File::PATH_SEPARATOR}#{original_path}"
672+
673+
Kamal::Cli::Main.expects(:exec).with(".kamal/bin/baz")
674+
with_argv([ "baz" ]) { Kamal::Cli::Main.start }
675+
ensure
676+
ENV["PATH"] = original_path
677+
end
678+
end
679+
end
680+
681+
test "builtin commands are not shadowed by external commands" do
682+
Dir.mktmpdir do |tmpdir|
683+
Dir.chdir(tmpdir) do
684+
FileUtils.mkdir_p(".kamal/bin")
685+
File.write(".kamal/bin/version", "#!/bin/sh\n")
686+
File.chmod(0755, ".kamal/bin/version")
687+
688+
Kamal::Cli::Main.expects(:exec).never
689+
with_argv([ "version" ]) { Kamal::Cli::Main.start }
690+
end
691+
end
692+
end
693+
694+
test "builtin prefix matches are not shadowed by external commands" do
695+
Dir.mktmpdir do |tmpdir|
696+
Dir.chdir(tmpdir) do
697+
FileUtils.mkdir_p(".kamal/bin")
698+
File.write(".kamal/bin/ver", "#!/bin/sh\n")
699+
File.chmod(0755, ".kamal/bin/ver")
700+
701+
Kamal::Cli::Main.expects(:exec).never
702+
with_argv([ "ver" ]) { Kamal::Cli::Main.start }
703+
end
704+
end
705+
end
706+
707+
test "external command passes arguments through" do
708+
Dir.mktmpdir do |tmpdir|
709+
Dir.chdir(tmpdir) do
710+
FileUtils.mkdir_p(".kamal/bin")
711+
File.write(".kamal/bin/foo", "#!/bin/sh\n")
712+
File.chmod(0755, ".kamal/bin/foo")
713+
714+
Kamal::Cli::Main.expects(:exec).with(".kamal/bin/foo", "--bar", "baz")
715+
with_argv([ "foo", "--bar", "baz" ]) { Kamal::Cli::Main.start }
716+
end
717+
end
718+
end
719+
720+
test "non-executable file in .kamal/bin is skipped" do
721+
Dir.mktmpdir do |tmpdir|
722+
Dir.chdir(tmpdir) do
723+
FileUtils.mkdir_p(".kamal/bin")
724+
File.write(".kamal/bin/foo", "#!/bin/sh\n")
725+
File.chmod(0644, ".kamal/bin/foo")
726+
727+
Kamal::Cli::Main.expects(:exec).never
728+
with_argv([ "foo" ]) do
729+
error = assert_raises(RuntimeError) { Kamal::Cli::Main.start }
730+
assert_match /Configuration file not found/, error.message
731+
end
732+
end
733+
end
734+
end
735+
736+
test "directory in .kamal/bin is skipped" do
737+
Dir.mktmpdir do |tmpdir|
738+
Dir.chdir(tmpdir) do
739+
FileUtils.mkdir_p(".kamal/bin/foo")
740+
741+
Kamal::Cli::Main.expects(:exec).never
742+
with_argv([ "foo" ]) do
743+
error = assert_raises(RuntimeError) { Kamal::Cli::Main.start }
744+
assert_match /Configuration file not found/, error.message
745+
end
746+
end
747+
end
748+
end
749+
750+
test "directory on PATH is skipped" do
751+
Dir.mktmpdir do |tmpdir|
752+
Dir.chdir(tmpdir) do
753+
bin_dir = File.join(tmpdir, "bin")
754+
FileUtils.mkdir_p(File.join(bin_dir, "kamal-foo"))
755+
756+
original_path = ENV["PATH"]
757+
ENV["PATH"] = "#{bin_dir}#{File::PATH_SEPARATOR}#{original_path}"
758+
759+
Kamal::Cli::Main.expects(:exec).never
760+
with_argv([ "foo" ]) do
761+
error = assert_raises(RuntimeError) { Kamal::Cli::Main.start }
762+
assert_match /Configuration file not found/, error.message
763+
end
764+
ensure
765+
ENV["PATH"] = original_path
766+
end
767+
end
768+
end
769+
770+
test "path traversal in command name is rejected" do
771+
Dir.mktmpdir do |tmpdir|
772+
Dir.chdir(tmpdir) do
773+
FileUtils.mkdir_p(".kamal/bin")
774+
target = File.join(tmpdir, "evil")
775+
File.write(target, "#!/bin/sh\n")
776+
File.chmod(0755, target)
777+
File.symlink(target, ".kamal/bin/../foo")
778+
779+
Kamal::Cli::Main.expects(:exec).never
780+
with_argv([ "../foo" ]) do
781+
error = assert_raises(RuntimeError) { Kamal::Cli::Main.start }
782+
assert_match /Configuration file not found/, error.message
783+
end
784+
end
785+
end
786+
end
787+
626788
test "upgrade rolling" do
627789
invoke_options = { "config_file" => "test/fixtures/deploy_with_accessories.yml", "skip_hooks" => false, "confirmed" => true, "rolling" => false }
628790
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:upgrade", [], invoke_options).times(4)

0 commit comments

Comments
 (0)