Repository: basecamp/kamal (Ruby)

Issue:
interactive server exec doesn't run hooks
Kamal v2.2.2

Running `kamal server exec` with the interactive flag doesn't execute the pre-connect hook

```
$ kamal server exec -d qa -i /bin/bash
Running '/bin/bash' on x.x.x.x interactively...
rails@x.x.x.x: Permission denied (publickey).
```

(the authentication failure is down to the fact that the pre-connect hook uses EC2 instance connect to send a throwaway SSH key to the instance before the connection attempt)

```
$ kamal server exec -d qa date
Running 'date' on x.x.x.x...
Running the pre-connect hook...
  INFO [] Running /usr/bin/env .kamal/hooks/pre-connect as cromega@localhost
  INFO [] Finished in 6.877 seconds with exit status 0 (successful).
  INFO [] Running /usr/bin/env date on x.x.x.x
  INFO [] Finished in 0.219 seconds with exit status 0 (successful).
App Host: x.x.x.x
Thu Oct 24 11:16:36 AM UTC 2024
```




Discussion:

Patch:
diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb
index 735d554e9..e906b3b32 100644
--- a/lib/kamal/cli/accessory.rb
+++ b/lib/kamal/cli/accessory.rb
@@ -141,6 +141,8 @@ def details(name)
   option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
   option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
   def exec(name, *cmd)
+    pre_connect_if_required
+
     cmd = Kamal::Utils.join_commands(cmd)
     with_accessory(name) do |accessory, hosts|
       case
diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb
index 54d8a1649..a449efdf7 100644
--- a/lib/kamal/cli/app.rb
+++ b/lib/kamal/cli/app.rb
@@ -106,6 +106,8 @@ def details
   option :env, aliases: "-e", type: :hash, desc: "Set environment variables for the command"
   option :detach, type: :boolean, default: false, desc: "Execute command in a detached container"
   def exec(*cmd)
+    pre_connect_if_required
+
     if (incompatible_options = [ :interactive, :reuse ].select { |key| options[:detach] && options[key] }.presence)
       raise ArgumentError, "Detach is not compatible with #{incompatible_options.join(" or ")}"
     end
diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb
index 0a6dd6e71..45b1411e1 100644
--- a/lib/kamal/cli/base.rb
+++ b/lib/kamal/cli/base.rb
@@ -147,12 +147,16 @@ def run_hook(hook, **extra_details)
       end
 
       def on(*args, &block)
+        pre_connect_if_required
+
+        super
+      end
+
+      def pre_connect_if_required
         if !KAMAL.connected?
           run_hook "pre-connect"
           KAMAL.connected = true
         end
-
-        super
       end
 
       def command
diff --git a/lib/kamal/cli/server.rb b/lib/kamal/cli/server.rb
index db464e7b9..e3da8c182 100644
--- a/lib/kamal/cli/server.rb
+++ b/lib/kamal/cli/server.rb
@@ -2,6 +2,8 @@ class Kamal::Cli::Server < Kamal::Cli::Base
   desc "exec", "Run a custom command on the server (use --help to show options)"
   option :interactive, type: :boolean, aliases: "-i", default: false, desc: "Run the command interactively (use for console/bash)"
   def exec(*cmd)
+    pre_connect_if_required
+
     cmd = Kamal::Utils.join_commands(cmd)
     hosts = KAMAL.hosts
 


Test patch:
diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb
index 2ed001829..c39176c36 100644
--- a/test/cli/app_test.rb
+++ b/test/cli/app_test.rb
@@ -334,18 +334,24 @@ class CliAppTest < CliTestCase
   end
 
   test "exec interactive" do
+    Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
     SSHKit::Backend::Abstract.any_instance.expects(:exec)
       .with("ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:latest ruby -v'")
+
     run_command("exec", "-i", "ruby -v").tap do |output|
+      assert_hook_ran "pre-connect", output
       assert_match "Get most recent version available as an image...", output
       assert_match "Launching interactive command with version latest via SSH from new container on 1.1.1.1...", output
     end
   end
 
   test "exec interactive with reuse" do
+    Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
     SSHKit::Backend::Abstract.any_instance.expects(:exec)
       .with("ssh -t root@1.1.1.1 -p 22 'docker exec -it app-web-999 ruby -v'")
+
     run_command("exec", "-i", "--reuse", "ruby -v").tap do |output|
+      assert_hook_ran "pre-connect", output
       assert_match "Get current version of running container...", output
       assert_match "Running /usr/bin/env sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done on 1.1.1.1", output
       assert_match "Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...", output