indocker 0.5.4 → 0.6.2

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: 70bf1241f0783dede869906f9f359c2a2137778a3059acaea9ae2fc7db30bb38
4
- data.tar.gz: f4d906fc4ae36b9bf4482c7e5802c03b4a8633c7f839a0ffb71bbfc1571a5e30
3
+ metadata.gz: 10915e6da5c1e8d2899b510a223d8761a0542880776b61e8078d38d23e261b28
4
+ data.tar.gz: dd047c45c09dd0f26b95ae668f00266c2f45c46ff1c4aecff14ef0155d9e5a59
5
5
  SHA512:
6
- metadata.gz: d45a4d6ab954e10b76fbaf2b4ca098c1368fd6b6365756f24bb210757d5f221d0fcfcf5b11b378cc5e31de38d72967158c90d91aaa3c9df53334f8a2596f12f0
7
- data.tar.gz: a0ae86f49931e812d34761ff20f73f704a010b742edb1968dae8e70fc7798edbd75cab519f34632d8f6840ab0b87f5454c70773c50627ac10fc5693e263819cb
6
+ metadata.gz: b879a49d9defa1cbe4dc56685e0467ff070c9d0310a6fd5531766c941e7b51028aa1ace5817dec1638dec944ccba8afbbceffa062095a658fdb248c615095253
7
+ data.tar.gz: 70814bb24a5a66da13c36a6d1350403686b6a5672f184aa691179cfb40850b74e66b059fc82f249fc052ebe942c2ed26326570ce041270d561f25ca7bb67021c
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- indocker (0.5.4)
4
+ indocker (0.6.2)
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
 
@@ -46,6 +46,9 @@ class Indocker::Launchers::ConfigurationDeployer
46
46
  deployer = Indocker::ContainerDeployer.new(configuration: configuration, logger: @logger)
47
47
 
48
48
  @global_logger.info("Establishing ssh sessions to all servers...")
49
+
50
+ warm_jump_hosts(deploy_servers + build_servers)
51
+
49
52
  build_server_pool.create_sessions!
50
53
 
51
54
  build_servers = configuration
@@ -103,10 +106,36 @@ class Indocker::Launchers::ConfigurationDeployer
103
106
 
104
107
  private
105
108
 
109
+ # Opens the shared connection to each jump host before anything runs in parallel, so the
110
+ # fan-out reuses it instead of racing to create one connection per operation.
111
+ def warm_jump_hosts(servers)
112
+ servers.map(&:jump_host).compact.uniq.each do |jump_host|
113
+ @logger.info("Establishing shared connection to jump host #{jump_host}")
114
+ Indocker::SshSession.warm_jump_host(jump_host, logger: @logger)
115
+ end
116
+ end
117
+
106
118
  def wait_remote_operations(remote_operations)
107
119
  remote_operations.each do |remote_operation|
108
- if remote_operation.thread.join(Indocker.remote_operation_timeout).nil?
109
- @global_logger.error("Deployment aborted. Remote operation :#{remote_operation.operation} on server #{remote_operation.server.user}@#{remote_operation.server.host} did not finish during #{Indocker.remote_operation_timeout} seconds.")
120
+ server = remote_operation.server
121
+ target = "#{server.name} (#{server.user}@#{server.host})"
122
+
123
+ begin
124
+ if remote_operation.thread.join(Indocker.remote_operation_timeout).nil?
125
+ @global_logger.error("Deployment aborted. Remote operation :#{remote_operation.operation} on server #{target} did not finish during #{Indocker.remote_operation_timeout} seconds.")
126
+ exit 1
127
+ end
128
+ rescue StandardError => e
129
+ # Raised by the operation thread and re-raised here by #join, so the server
130
+ # that failed can be named instead of dumping a bare backtrace.
131
+ reason = e.message.to_s.empty? || e.message == e.class.name ? e.class.to_s : "#{e.class}: #{e.message}"
132
+
133
+ @global_logger.error("Deployment aborted. Remote operation :#{remote_operation.operation} on server #{target} failed: #{reason}")
134
+
135
+ if e.is_a?(Errno::ETIMEDOUT) || (defined?(Net::SSH::ConnectionTimeout) && e.is_a?(Net::SSH::ConnectionTimeout))
136
+ @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.")
137
+ end
138
+
110
139
  exit 1
111
140
  end
112
141
  end
@@ -380,13 +409,8 @@ class Indocker::Launchers::ConfigurationDeployer
380
409
  remote_operations += repositories.map do |alias_name, repository|
381
410
  @progress.start_syncing_repository(server, alias_name)
382
411
 
383
- thread = Thread.new do
384
- session = Indocker::SshSession.new(
385
- host: server.host,
386
- user: server.user,
387
- port: server.port,
388
- logger: @logger
389
- )
412
+ Indocker::Launchers::DTO::RemoteOperationDTO.spawn(server, :repository_pull) do
413
+ session = Indocker::SshSession.for(server, logger: @logger)
390
414
 
391
415
  if repository.is_local?
392
416
  @logger.info("Rsyncing repository :#{alias_name} from #{repository.root_path} to #{server.user}@#{server.host}:#{repository.clone_path}")
@@ -415,8 +439,6 @@ class Indocker::Launchers::ConfigurationDeployer
415
439
 
416
440
  @progress.finish_syncing_repository(server, alias_name)
417
441
  end
418
-
419
- Indocker::Launchers::DTO::RemoteOperationDTO.new(thread, server, :repository_pull)
420
442
  end
421
443
  end
422
444
 
@@ -455,6 +477,7 @@ class Indocker::Launchers::ConfigurationDeployer
455
477
  @logger.info("Updating crontab file #{deploy_user}:#{crontab_filepath}")
456
478
 
457
479
  ssh_options = "-o BatchMode=yes -o ConnectTimeout=#{Indocker.ssh_connect_timeout}"
480
+ ssh_options = "#{ssh_options} -J #{server.jump_host}" if server.jump_host
458
481
 
459
482
  Indocker::Shell.command("scp #{ssh_options} #{tmp_crontab_file.path} #{deploy_user}:#{crontab_filepath}", @logger)
460
483
 
@@ -467,13 +490,8 @@ class Indocker::Launchers::ConfigurationDeployer
467
490
  servers.map do |server|
468
491
  @progress.start_syncing_binaries(server)
469
492
 
470
- thread = Thread.new do
471
- session = Indocker::SshSession.new(
472
- host: server.host,
473
- user: server.user,
474
- port: server.port,
475
- logger: @logger
476
- )
493
+ Indocker::Launchers::DTO::RemoteOperationDTO.spawn(server, :indocker_sync) do
494
+ session = Indocker::SshSession.for(server, logger: @logger)
477
495
 
478
496
  sync_path = Indocker::IndockerHelper.indocker_dir
479
497
  @logger.info("Syncing indocker to #{server.user}@#{server.host}:#{sync_path}")
@@ -488,8 +506,6 @@ class Indocker::Launchers::ConfigurationDeployer
488
506
 
489
507
  @progress.finish_syncing_binaries(server)
490
508
  end
491
-
492
- Indocker::Launchers::DTO::RemoteOperationDTO.new(thread, server, :indocker_sync)
493
509
  end
494
510
  end
495
511
 
@@ -500,17 +516,12 @@ class Indocker::Launchers::ConfigurationDeployer
500
516
  remote_operations += env_files.map do |alias_name, env_file|
501
517
  @progress.start_syncing_env_file(server, alias_name)
502
518
 
503
- thread = Thread.new do
519
+ Indocker::Launchers::DTO::RemoteOperationDTO.spawn(server, :env_file_sync) do
504
520
  if env_file.is_a?(Indocker::EnvFiles::Local)
505
521
  sync_path = File.join(Indocker.deploy_dir, 'env_files', File.basename(env_file.path))
506
522
  @logger.info("Syncing env file :#{alias_name} from #{env_file.path} to #{server.user}@#{server.host}:#{sync_path}")
507
523
 
508
- session = Indocker::SshSession.new(
509
- host: server.host,
510
- user: server.user,
511
- port: server.port,
512
- logger: @logger
513
- )
524
+ session = Indocker::SshSession.for(server, logger: @logger)
514
525
 
515
526
  session.exec!("mkdir -p #{Indocker::EnvFileHelper.folder}")
516
527
 
@@ -529,8 +540,6 @@ class Indocker::Launchers::ConfigurationDeployer
529
540
 
530
541
  @progress.finish_syncing_env_file(server, alias_name)
531
542
  end
532
-
533
- Indocker::Launchers::DTO::RemoteOperationDTO.new(thread, server, :env_file_sync)
534
543
  end
535
544
  end
536
545
 
@@ -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
@@ -41,7 +41,18 @@ class Indocker::Rsync
41
41
  "-o ConnectTimeout=#{Indocker.ssh_connect_timeout}",
42
42
  "-o ServerAliveInterval=10",
43
43
  "-o ServerAliveCountMax=3",
44
- ].join(' ')
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. ProxyCommand
48
+ # rather than -J: it is the same tunnel every other remote operation uses, including
49
+ # the shared connection that keeps a parallel sync from exhausting the jump host's
50
+ # MaxStartups.
51
+ if session.jump_host
52
+ ssh_command << %Q{-o ProxyCommand="#{Indocker::SshSession.jump_proxy_command(session.jump_host)}"}
53
+ end
54
+
55
+ ssh_command = ssh_command.join(' ')
45
56
  command = "rsync --delete-after -a -e '#{ssh_command}' #{from} #{session.user}@#{session.host}:#{to}"
46
57
  Indocker.logger.debug("sync #{from} #{session.user}@#{session.host}:#{to}")
47
58
  Indocker::Shell.command(command, Indocker.logger, raise_on_error: raise_on_error)
@@ -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)
@@ -2,6 +2,7 @@ require_relative 'shell'
2
2
 
3
3
  class Indocker::SshSession
4
4
  LOCALHOST = 'localhost'.freeze
5
+ JUMP_CONTROL_PERSIST = '600'.freeze
5
6
 
6
7
  class ExecResult
7
8
  attr_reader :stdout_data, :stderr_data, :exit_code, :exit_signal
@@ -19,24 +20,94 @@ class Indocker::SshSession
19
20
  end
20
21
  end
21
22
 
22
- attr_reader :host, :user, :port, :logger
23
+ attr_reader :host, :user, :port, :logger, :jump_host
24
+
25
+ # Builds the tunnel to a jump host, shared by Net::SSH sessions and by rsync.
26
+ #
27
+ # Net::SSH::Proxy::Jump would build a bare `ssh -W`: a jump host that asks for a password
28
+ # hangs the deploy, and probing a server whose sshd is still starting prints
29
+ # "channel 0: open failed" from the ssh client on every retry.
30
+ #
31
+ # Multiplexing matters even more. A deploy syncs and deploys to every server in parallel,
32
+ # and each of those opens its own tunnel; sshd drops everything above MaxStartups (10 by
33
+ # default), which surfaces as "connection closed by remote host" on random servers. Sharing
34
+ # one connection to the jump host keeps the whole fan-out down to a single tunnel.
35
+ def self.jump_proxy_command(jump_host)
36
+ require 'digest'
37
+
38
+ host = jump_host
39
+ port = nil
40
+
41
+ if host =~ /\A(.+):(\d+)\z/
42
+ host = $1
43
+ port = $2
44
+ end
45
+
46
+ control_path = "/tmp/indocker-jump-#{Digest::MD5.hexdigest(jump_host)[0, 10]}"
47
+
48
+ command = [
49
+ "ssh -q",
50
+ "-o BatchMode=yes",
51
+ "-o ConnectTimeout=#{Indocker.ssh_connect_timeout}",
52
+ "-o ControlMaster=auto",
53
+ "-o ControlPath=#{control_path}",
54
+ "-o ControlPersist=#{JUMP_CONTROL_PERSIST}",
55
+ ]
56
+
57
+ command << "-p #{port}" if port
58
+ command << "-W %h:%p"
59
+ command << host
23
60
 
24
- def initialize(host:, user:, port:, logger:)
61
+ command.join(' ')
62
+ end
63
+
64
+ # Opens the shared connection to a jump host up front. Without it the first wave of
65
+ # parallel operations races to create the master connection and opens a burst of them.
66
+ def self.warm_jump_host(jump_host, logger:)
67
+ command = jump_proxy_command(jump_host).sub("-W %h:%p ", "")
68
+
69
+ Indocker::Shell.command("#{command} true", logger)
70
+ end
71
+
72
+ # Opens a session to a server as it is defined in the configuration, including its
73
+ # jump host, if any. Every remote operation goes through here, so a server reached
74
+ # through a jump host is reached that way by binaries sync, container deploys and
75
+ # the deployment checker alike.
76
+ def self.for(server, logger:)
77
+ new(
78
+ host: server.host,
79
+ user: server.user,
80
+ port: server.port,
81
+ jump_host: server.jump_host,
82
+ logger: logger
83
+ )
84
+ end
85
+
86
+ def initialize(host:, user:, port:, logger:, jump_host: nil)
25
87
  @host = host
26
88
  @user = user
27
89
  @port = port
90
+ @jump_host = jump_host
28
91
  @logger = logger
29
92
 
30
93
  if host != LOCALHOST
31
94
  require 'net/ssh'
32
- @ssh = Net::SSH.start(@host, @user, {
95
+
96
+ options = {
33
97
  port: @port,
34
98
  non_interactive: true,
35
99
  timeout: Indocker.ssh_connect_timeout,
36
100
  keepalive: true,
37
101
  keepalive_interval: 10,
38
102
  keepalive_maxcount: 3,
39
- })
103
+ }
104
+
105
+ if @jump_host
106
+ require 'net/ssh/proxy/command'
107
+ options[:proxy] = Net::SSH::Proxy::Command.new(self.class.jump_proxy_command(@jump_host))
108
+ end
109
+
110
+ @ssh = Net::SSH.start(@host, @user, options)
40
111
  end
41
112
  end
42
113
 
@@ -1,3 +1,3 @@
1
1
  module Indocker
2
- VERSION = "0.5.4"
2
+ VERSION = "0.6.2"
3
3
  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.4
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ruslan Gatiyatov