Class: SQLiteSweep::SSH::ConnectionManager

Inherits:
Object
  • Object
show all
Defined in:
lib/sqlitesweep/ssh/connection_manager.rb

Overview

Manages SSH ControlMaster connections for multiplexed remote access.

Why ControlMaster instead of net-ssh?

- net-ssh is not thread-safe for concurrent operations on a single session
- System ssh with ControlMaster handles multiplexing natively
- SSH agent, config files, and known_hosts all work automatically

How it works:

1. On first query to a host, establishes a background master connection
   (ssh -N -f with ControlMaster=yes) that stays alive via ControlPersist
2. Subsequent SSH commands to the same host multiplex over the existing
   master via ControlPath — no new TCP/auth handshake needed
3. A semaphore enforces --max-ssh to cap total master connections
4. On shutdown, sends "ssh -O exit" to each master and cleans up sockets

Socket files live in /tmp/sqlitesweep_ssh_<pid>/ and are cleaned up on shutdown.

Instance Method Summary collapse

Constructor Details

#initialize(config) ⇒ ConnectionManager

Returns a new instance of ConnectionManager.



26
27
28
29
30
31
32
33
# File 'lib/sqlitesweep/ssh/connection_manager.rb', line 26

def initialize(config)
  @config = config
  @socket_dir = File.join("/tmp", "sqlitesweep_ssh_#{Process.pid}")
  FileUtils.mkdir_p(@socket_dir)
  @semaphore = Concurrent::Semaphore.new(config.max_ssh)
  @masters = Concurrent::Map.new  # host_key => true (thread-safe set)
  @mutex = Mutex.new
end

Instance Method Details

#ensure_master(host_key) ⇒ Object

Ensures a ControlMaster connection exists for the given host. If one already exists, returns immediately. Otherwise, acquires a semaphore permit and establishes a new master.

Uses BatchMode=yes to prevent password prompts (fails fast if key-based auth isn’t configured). StrictHostKeyChecking=accept-new auto-accepts new hosts but rejects changed keys.

Parameters:

  • host_key (String)

    The SSH destination (e.g. “deploy@web1”).

Raises:

  • (SSHError)

    If the master connection fails to establish.



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/sqlitesweep/ssh/connection_manager.rb', line 53

def ensure_master(host_key)
  return if @masters[host_key]

  @semaphore.acquire
  begin
    # Double-check after acquiring semaphore (another thread may have
    # established the master while we were waiting)
    return if @masters[host_key]

    socket = socket_path(host_key)
    cmd = [
      "ssh",
      "-o", "ControlMaster=yes",
      "-o", "ControlPath=#{socket}",
      "-o", "ControlPersist=120",
      "-o", "BatchMode=yes",
      "-o", "StrictHostKeyChecking=accept-new",
      "-o", "ConnectTimeout=#{@config.ssh_timeout}",
      "-N", "-f",  # No command, go to background
      host_key
    ]

    _out, err, status = Open3.capture3(*cmd)
    unless status.success?
      @semaphore.release
      raise SSHError, "Failed to establish SSH master to #{host_key}: #{err.strip}"
    end

    @masters[host_key] = true
  rescue SSHError
    raise
  rescue => e
    @semaphore.release
    raise SSHError, "SSH connection error to #{host_key}: #{e.message}"
  end
end

#run(host_key, remote_command, timeout: nil) ⇒ String

Runs a command on a remote host over the multiplexed SSH connection. Ensures a ControlMaster exists first, then executes via ControlPath.

Parameters:

  • host_key (String)

    The SSH destination.

  • remote_command (String)

    The shell command to run remotely.

  • timeout (Integer, nil) (defaults to: nil)

    Override timeout in seconds (defaults to config.query_timeout).

Returns:

  • (String)

    The command’s stdout.

Raises:

  • (QueryError)

    If the remote command exits non-zero.

  • (TimeoutError)

    If the command exceeds the timeout.



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/sqlitesweep/ssh/connection_manager.rb', line 99

def run(host_key, remote_command, timeout: nil)
  ensure_master(host_key)

  socket = socket_path(host_key)
  cmd = [
    "ssh",
    "-o", "ControlPath=#{socket}",
    "-o", "BatchMode=yes",
    host_key,
    remote_command
  ]

  timeout ||= @config.query_timeout
  out, err, status = execute_with_timeout(cmd, timeout)

  unless status.success?
    raise QueryError, "Remote command failed on #{host_key}: #{err.strip}"
  end

  out
end

#shutdownObject

Shuts down all ControlMaster connections and cleans up socket files. Sends “ssh -O exit” to each master to gracefully close it.



123
124
125
126
127
128
129
130
131
# File 'lib/sqlitesweep/ssh/connection_manager.rb', line 123

def shutdown
  @masters.each_key do |host_key|
    socket = socket_path(host_key)
    system("ssh", "-o", "ControlPath=#{socket}", "-O", "exit", host_key,
           out: File::NULL, err: File::NULL)
  end
  @masters.clear
  FileUtils.rm_rf(@socket_dir) if Dir.exist?(@socket_dir)
end

#socket_path(host_key) ⇒ String

Returns the Unix socket path for a given host’s ControlMaster.

Parameters:

  • host_key (String)

    The SSH destination (e.g. “deploy@web1”).

Returns:

  • (String)

    Path to the socket file.



39
40
41
# File 'lib/sqlitesweep/ssh/connection_manager.rb', line 39

def socket_path(host_key)
  File.join(@socket_dir, host_key.gsub("/", "_"))
end