indocker 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 738373105e7204366594f9789bc989e5c83453bc209fe0053485ef239156cc7a
4
- data.tar.gz: 63dc5708eee2e75caab7a4bfaa8ac9c0e80c73fb46fbca987215d28309f07d86
3
+ metadata.gz: 0eda49208a82e456326a838e76cab5152673a078e30668832c97c7c1b525a4fa
4
+ data.tar.gz: 5904dabfde8e6110821b104be6fd0809799a5deb3b35f991bed33627323bf1c9
5
5
  SHA512:
6
- metadata.gz: 477f6ae6ffd9fa9087743f06e354d751af669e803c4f9f5a7ee74c820bbfb5872674c379a0a006d46200f5fe174bf7f1779b088df093b6dfeabe51c2fcd3e7b9
7
- data.tar.gz: 355bee8644ae9e6e478cc878f41ee26c3f73bcf31c951561dc80fdc2c4bdde162e1d3d0bd387633d74501767c2b089a3f7cb20610795c5a6d2808a2b61dd079f
6
+ metadata.gz: '020961f0cae0f634adf95f355d2e537bca061ec0380294f0a12ca75d0e72b54f19eb57d357dbe1f1dbef50cfd5d43896ed7f80107fe7ac822e6d3addd2645586'
7
+ data.tar.gz: f9ff2feb5c3288474635a3e7d31354c8065075068872865f51b6542e1c6933c0342e253260c58492cc7eb99e3ae09e1904692a9b1acd5a13751d467ca0edaf5d
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- indocker (0.5.3)
4
+ indocker (0.6.1)
5
5
  net-ssh
6
6
 
7
7
  GEM
@@ -13,14 +13,9 @@ class Indocker::Artifacts::Services::Synchronizer
13
13
  remote_operations += servers.map do |server|
14
14
  @progress.start_syncing_artifact(server, artifact)
15
15
 
16
- thread = Thread.new do
16
+ Indocker::Launchers::DTO::RemoteOperationDTO.spawn(server, :artifact_sync) do
17
17
  server.synchronize do
18
- session = Indocker::SshSession.new(
19
- host: server.host,
20
- user: server.user,
21
- port: server.port,
22
- logger: @logger
23
- )
18
+ session = Indocker::SshSession.for(server, logger: @logger)
24
19
 
25
20
  if artifact.is_git?
26
21
  @logger.info("Pulling git artifact #{artifact.name.to_s.green} for #{server.user}@#{server.host}")
@@ -49,8 +44,6 @@ class Indocker::Artifacts::Services::Synchronizer
49
44
  @progress.finish_syncing_artifact(server, artifact)
50
45
  end
51
46
  end
52
-
53
- Indocker::Launchers::DTO::RemoteOperationDTO.new(thread, server, :artifact_sync)
54
47
  end
55
48
  end
56
49
 
@@ -58,12 +58,7 @@ class Indocker::DeploymentChecker
58
58
  total_missing_containers = []
59
59
 
60
60
  server_list.each do |server|
61
- session = Indocker::SshSession.new(
62
- host: server.host,
63
- user: server.user,
64
- port: server.port,
65
- logger: @debug_logger
66
- )
61
+ session = Indocker::SshSession.for(server, logger: @debug_logger)
67
62
 
68
63
  result = session.exec!('docker ps --filter="status=running" --format="{{.Names}}"')
69
64
 
@@ -1,9 +1,7 @@
1
- require 'timeout'
2
1
  require 'benchmark'
3
2
  require 'tempfile'
4
3
 
5
4
  class Indocker::Launchers::ConfigurationDeployer
6
- REMOTE_OPERATION_TIMEOUT = 60
7
5
 
8
6
  def initialize(logger:, global_logger:)
9
7
  Thread.abort_on_exception = true # abort all threads if exception occurs
@@ -107,12 +105,25 @@ class Indocker::Launchers::ConfigurationDeployer
107
105
 
108
106
  def wait_remote_operations(remote_operations)
109
107
  remote_operations.each do |remote_operation|
108
+ server = remote_operation.server
109
+ target = "#{server.name} (#{server.user}@#{server.host})"
110
+
110
111
  begin
111
- Timeout::timeout(REMOTE_OPERATION_TIMEOUT) do
112
- remote_operation.thread.join
112
+ if remote_operation.thread.join(Indocker.remote_operation_timeout).nil?
113
+ @global_logger.error("Deployment aborted. Remote operation :#{remote_operation.operation} on server #{target} did not finish during #{Indocker.remote_operation_timeout} seconds.")
114
+ exit 1
115
+ end
116
+ rescue StandardError => e
117
+ # Raised by the operation thread and re-raised here by #join, so the server
118
+ # that failed can be named instead of dumping a bare backtrace.
119
+ reason = e.message.to_s.empty? || e.message == e.class.name ? e.class.to_s : "#{e.class}: #{e.message}"
120
+
121
+ @global_logger.error("Deployment aborted. Remote operation :#{remote_operation.operation} on server #{target} failed: #{reason}")
122
+
123
+ if e.is_a?(Errno::ETIMEDOUT) || (defined?(Net::SSH::ConnectionTimeout) && e.is_a?(Net::SSH::ConnectionTimeout))
124
+ @global_logger.error("Server #{server.host} did not accept a connection within #{Indocker.ssh_connect_timeout} seconds. It is powered off, or its address does not accept TCP.")
113
125
  end
114
- rescue Timeout::Error
115
- @global_logger.error("Deployment aborted. Remote operation :#{remote_operation.operation} on server #{remote_operation.server.user}@#{remote_operation.server.host} did not finish during #{REMOTE_OPERATION_TIMEOUT} seconds.")
126
+
116
127
  exit 1
117
128
  end
118
129
  end
@@ -386,13 +397,8 @@ class Indocker::Launchers::ConfigurationDeployer
386
397
  remote_operations += repositories.map do |alias_name, repository|
387
398
  @progress.start_syncing_repository(server, alias_name)
388
399
 
389
- thread = Thread.new do
390
- session = Indocker::SshSession.new(
391
- host: server.host,
392
- user: server.user,
393
- port: server.port,
394
- logger: @logger
395
- )
400
+ Indocker::Launchers::DTO::RemoteOperationDTO.spawn(server, :repository_pull) do
401
+ session = Indocker::SshSession.for(server, logger: @logger)
396
402
 
397
403
  if repository.is_local?
398
404
  @logger.info("Rsyncing repository :#{alias_name} from #{repository.root_path} to #{server.user}@#{server.host}:#{repository.clone_path}")
@@ -421,8 +427,6 @@ class Indocker::Launchers::ConfigurationDeployer
421
427
 
422
428
  @progress.finish_syncing_repository(server, alias_name)
423
429
  end
424
-
425
- Indocker::Launchers::DTO::RemoteOperationDTO.new(thread, server, :repository_pull)
426
430
  end
427
431
  end
428
432
 
@@ -460,24 +464,22 @@ class Indocker::Launchers::ConfigurationDeployer
460
464
 
461
465
  @logger.info("Updating crontab file #{deploy_user}:#{crontab_filepath}")
462
466
 
463
- Indocker::Shell.command("scp #{tmp_crontab_file.path} #{deploy_user}:#{crontab_filepath}", @logger)
467
+ ssh_options = "-o BatchMode=yes -o ConnectTimeout=#{Indocker.ssh_connect_timeout}"
468
+ ssh_options = "#{ssh_options} -J #{server.jump_host}" if server.jump_host
469
+
470
+ Indocker::Shell.command("scp #{ssh_options} #{tmp_crontab_file.path} #{deploy_user}:#{crontab_filepath}", @logger)
464
471
 
465
472
  tmp_crontab_file.unlink
466
473
 
467
- Indocker::Shell.command("ssh #{deploy_user} 'crontab #{crontab_filepath}'", @logger)
474
+ Indocker::Shell.command("ssh #{ssh_options} #{deploy_user} 'crontab #{crontab_filepath}'", @logger)
468
475
  end
469
476
 
470
477
  def sync_indocker(servers)
471
478
  servers.map do |server|
472
479
  @progress.start_syncing_binaries(server)
473
480
 
474
- thread = Thread.new do
475
- session = Indocker::SshSession.new(
476
- host: server.host,
477
- user: server.user,
478
- port: server.port,
479
- logger: @logger
480
- )
481
+ Indocker::Launchers::DTO::RemoteOperationDTO.spawn(server, :indocker_sync) do
482
+ session = Indocker::SshSession.for(server, logger: @logger)
481
483
 
482
484
  sync_path = Indocker::IndockerHelper.indocker_dir
483
485
  @logger.info("Syncing indocker to #{server.user}@#{server.host}:#{sync_path}")
@@ -492,8 +494,6 @@ class Indocker::Launchers::ConfigurationDeployer
492
494
 
493
495
  @progress.finish_syncing_binaries(server)
494
496
  end
495
-
496
- Indocker::Launchers::DTO::RemoteOperationDTO.new(thread, server, :indocker_sync)
497
497
  end
498
498
  end
499
499
 
@@ -504,17 +504,12 @@ class Indocker::Launchers::ConfigurationDeployer
504
504
  remote_operations += env_files.map do |alias_name, env_file|
505
505
  @progress.start_syncing_env_file(server, alias_name)
506
506
 
507
- thread = Thread.new do
507
+ Indocker::Launchers::DTO::RemoteOperationDTO.spawn(server, :env_file_sync) do
508
508
  if env_file.is_a?(Indocker::EnvFiles::Local)
509
509
  sync_path = File.join(Indocker.deploy_dir, 'env_files', File.basename(env_file.path))
510
510
  @logger.info("Syncing env file :#{alias_name} from #{env_file.path} to #{server.user}@#{server.host}:#{sync_path}")
511
511
 
512
- session = Indocker::SshSession.new(
513
- host: server.host,
514
- user: server.user,
515
- port: server.port,
516
- logger: @logger
517
- )
512
+ session = Indocker::SshSession.for(server, logger: @logger)
518
513
 
519
514
  session.exec!("mkdir -p #{Indocker::EnvFileHelper.folder}")
520
515
 
@@ -533,8 +528,6 @@ class Indocker::Launchers::ConfigurationDeployer
533
528
 
534
529
  @progress.finish_syncing_env_file(server, alias_name)
535
530
  end
536
-
537
- Indocker::Launchers::DTO::RemoteOperationDTO.new(thread, server, :env_file_sync)
538
531
  end
539
532
  end
540
533
 
@@ -1,10 +1,27 @@
1
1
  class Indocker::Launchers::DTO::RemoteOperationDTO
2
2
  attr_reader :thread, :server, :operation, :message
3
3
 
4
+ # Runs a remote operation in a thread that keeps its failure to itself, so that
5
+ # wait_remote_operations can re-raise it on join and report which server failed.
6
+ # Left to the global abort_on_exception/report_on_exception defaults, a failed ssh
7
+ # dumps a raw backtrace and tears the deploy down from inside the thread, naming
8
+ # no server at all. The flags are set inside the thread so that they are in place
9
+ # before the operation gets a chance to fail.
10
+ def self.spawn(server, operation, message = nil, &block)
11
+ thread = Thread.new do
12
+ Thread.current.abort_on_exception = false
13
+ Thread.current.report_on_exception = false
14
+
15
+ block.call
16
+ end
17
+
18
+ new(thread, server, operation, message)
19
+ end
20
+
4
21
  def initialize(thread, server, operation, message = nil)
5
22
  @thread = thread
6
23
  @server = server
7
24
  @operation = operation
8
25
  @message = message
9
26
  end
10
- end
27
+ end
@@ -29,7 +29,26 @@ class Indocker::Rsync
29
29
  sync_local_cp(from, to, raise_on_error: raise_on_error)
30
30
  end
31
31
  else
32
- command = "rsync --delete-after -a -e 'ssh -p #{session.port}' #{from} #{session.user}@#{session.host}:#{to}"
32
+ # BatchMode/ConnectTimeout: an unreachable server must fail in seconds with
33
+ # a clear rsync error instead of hanging silently (or prompting for a password)
34
+ # until the deployment-wide remote operation timeout kills the whole deploy.
35
+ # ServerAlive*: a path that accepts the connection and then stops delivering
36
+ # packets (seen when a cloud NAT address half-works) would otherwise hang
37
+ # just as long — keepalives tear such a session down in ~30s.
38
+ ssh_command = [
39
+ "ssh -p #{session.port}",
40
+ "-o BatchMode=yes",
41
+ "-o ConnectTimeout=#{Indocker.ssh_connect_timeout}",
42
+ "-o ServerAliveInterval=10",
43
+ "-o ServerAliveCountMax=3",
44
+ ]
45
+
46
+ # Tunnel through the jump host the server is defined with, so that a server
47
+ # reachable only over its internal address syncs like any other one.
48
+ ssh_command << "-J #{session.jump_host}" if session.jump_host
49
+
50
+ ssh_command = ssh_command.join(' ')
51
+ command = "rsync --delete-after -a -e '#{ssh_command}' #{from} #{session.user}@#{session.host}:#{to}"
33
52
  Indocker.logger.debug("sync #{from} #{session.user}@#{session.host}:#{to}")
34
53
  Indocker::Shell.command(command, Indocker.logger, raise_on_error: raise_on_error)
35
54
  end
@@ -1,13 +1,17 @@
1
1
  class Indocker::Server
2
2
  include Indocker::Concerns::Inspectable
3
3
 
4
- attr_reader :name, :host, :user, :port
4
+ attr_reader :name, :host, :user, :port, :jump_host
5
5
 
6
- def initialize(name:, host:, user:, port:)
6
+ # jump_host ("user@host") makes every connection to this server tunnel through
7
+ # another one. Servers whose public address is unreachable can still be deployed
8
+ # to over their internal address that way.
9
+ def initialize(name:, host:, user:, port:, jump_host: nil)
7
10
  @name = name
8
11
  @host = host
9
12
  @user = user
10
13
  @port = port
14
+ @jump_host = jump_host
11
15
  end
12
16
 
13
17
  def ==(value)
@@ -10,12 +10,7 @@ class Indocker::ServerPools::ServerConnection
10
10
  def create_session!
11
11
  return unless @server
12
12
 
13
- @session = Indocker::SshSession.new(
14
- host: @server.host,
15
- user: @server.user,
16
- port: @server.port,
17
- logger: @logger
18
- )
13
+ @session = Indocker::SshSession.for(@server, logger: @logger)
19
14
  end
20
15
 
21
16
  def exec!(command)
@@ -19,17 +19,47 @@ class Indocker::SshSession
19
19
  end
20
20
  end
21
21
 
22
- attr_reader :host, :user, :port, :logger
22
+ attr_reader :host, :user, :port, :logger, :jump_host
23
+
24
+ # Opens a session to a server as it is defined in the configuration, including its
25
+ # jump host, if any. Every remote operation goes through here, so a server reached
26
+ # through a jump host is reached that way by binaries sync, container deploys and
27
+ # the deployment checker alike.
28
+ def self.for(server, logger:)
29
+ new(
30
+ host: server.host,
31
+ user: server.user,
32
+ port: server.port,
33
+ jump_host: server.jump_host,
34
+ logger: logger
35
+ )
36
+ end
23
37
 
24
- def initialize(host:, user:, port:, logger:)
38
+ def initialize(host:, user:, port:, logger:, jump_host: nil)
25
39
  @host = host
26
40
  @user = user
27
41
  @port = port
42
+ @jump_host = jump_host
28
43
  @logger = logger
29
44
 
30
45
  if host != LOCALHOST
31
46
  require 'net/ssh'
32
- @ssh = Net::SSH.start(@host, @user, {port: @port})
47
+
48
+ options = {
49
+ port: @port,
50
+ non_interactive: true,
51
+ timeout: Indocker.ssh_connect_timeout,
52
+ keepalive: true,
53
+ keepalive_interval: 10,
54
+ keepalive_maxcount: 3,
55
+ }
56
+
57
+ if @jump_host
58
+ require 'net/ssh/proxy/command'
59
+ options[:proxy] = Net::SSH::Proxy::Command.new(jump_proxy_command)
60
+ end
61
+
62
+ @ssh = Net::SSH.start(@host, @user, options)
33
63
  end
34
64
  end
35
65
 
@@ -52,6 +82,32 @@ class Indocker::SshSession
52
82
  end
53
83
 
54
84
  private
85
+ # Net::SSH::Proxy::Jump builds this same `ssh -W` tunnel, but without BatchMode,
86
+ # connect timeout or -q: a jump host that asks for a password hangs the deploy, and
87
+ # every probe of a server whose sshd is still starting prints "channel 0: open failed"
88
+ # from the ssh client.
89
+ def jump_proxy_command
90
+ host = @jump_host
91
+ port = nil
92
+
93
+ if host =~ /\A(.+):(\d+)\z/
94
+ host = $1
95
+ port = $2
96
+ end
97
+
98
+ command = [
99
+ "ssh -q",
100
+ "-o BatchMode=yes",
101
+ "-o ConnectTimeout=#{Indocker.ssh_connect_timeout}",
102
+ ]
103
+
104
+ command << "-p #{port}" if port
105
+ command << "-W %h:%p"
106
+ command << host
107
+
108
+ command.join(' ')
109
+ end
110
+
55
111
  def exec_locally!(command)
56
112
  res = Indocker::Shell.command_with_result(command, @logger, skip_logging: false)
57
113
  ExecResult.new(res.stdout, '', res.exit_status, nil)
@@ -1,3 +1,3 @@
1
1
  module Indocker
2
- VERSION = "0.5.3"
2
+ VERSION = "0.6.1"
3
3
  end
data/lib/indocker.rb CHANGED
@@ -135,6 +135,22 @@ module Indocker
135
135
  @redeploy_crontab_path = val
136
136
  end
137
137
 
138
+ def set_remote_operation_timeout(seconds)
139
+ @remote_operation_timeout = seconds
140
+ end
141
+
142
+ def remote_operation_timeout
143
+ @remote_operation_timeout || 300
144
+ end
145
+
146
+ def set_ssh_connect_timeout(seconds)
147
+ @ssh_connect_timeout = seconds
148
+ end
149
+
150
+ def ssh_connect_timeout
151
+ @ssh_connect_timeout || 15
152
+ end
153
+
138
154
  def redeploy_crontab_path
139
155
  @redeploy_crontab_path
140
156
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: indocker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ruslan Gatiyatov
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-07-07 00:00:00.000000000 Z
12
+ date: 2026-08-18 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: net-ssh