herd-rb 0.3.0 → 0.5.0

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: f0b080c1095b56bac42536acc7df54cd20c97bac52f2367301e2b951eb8a60be
4
- data.tar.gz: 397b3c9636a2dc20310f8e70fbe3911049bde76183fe8650f30d0cff4bb40402
3
+ metadata.gz: ded7f78ffd316333cc6e7177d7ec84ad9ac91ec6cd9b10a9b3e55c7851fa6e22
4
+ data.tar.gz: fb2aed4ff5ae882238b6a38905d0685af26a03d11149ae3e1cfe2d4846287e08
5
5
  SHA512:
6
- metadata.gz: 514947c517caa05fc2229c6d293042bf6065ad2d067574087d479772fac271802279f769a6f98d33ae0c7a09720bd99e6592b475e2e6cc3934c515d970323bc1
7
- data.tar.gz: 7962d18779bc695f3858adcbf40dc75350e9d88ec00a29b0ab71b8e9ab8ffd21b18f28e8078c2e012b33612c400941d7394ce5254fa52520be8988bc59d11784
6
+ metadata.gz: ccaa31336a819b2e845f0a713696303900a049f764d5eb7b1233e554f15fcf497a2d74109a2bf293e3d309a82bc711ad07cd9d38acfac18811f32e9c29869250
7
+ data.tar.gz: 1088e0d0613804db1ef7a1c2f098bdc63bc01aadf391ab4e712019f65461f4fc8d10ba65d84fd556f6065816c41213e7b5b6aed54b1db29028a2aa1555521d87
data/README.md CHANGED
@@ -6,7 +6,13 @@ Fast host configuration tool.
6
6
 
7
7
  * [x] Run with `sudo`
8
8
  * [x] Commands with arguments
9
- * [ ] Reading and writing files
9
+ * [x] Reading and writing files
10
+ * [x] Templates (ERB)
11
+ * [x] Copy dirs
12
+ * [ ] Compare with Rsync
13
+ * [x] Crontab
14
+ * [x] Log all commands for all hosts
15
+ * [ ] Add user to group
10
16
 
11
17
  ## Installation
12
18
 
@@ -55,6 +61,21 @@ runner.exec("hostname") # ["alpha001\n", "omega001\n"]
55
61
  runner.exec { hostname + uptime } # ["alpha001\n2000 years\n", "omega001\2500 years\n"]
56
62
  ```
57
63
 
64
+ List of hosts can be loaded from the CSV file:
65
+
66
+ ```ruby
67
+ # hosts.csv
68
+ host,port,user,password,some_param1,some_param2
69
+ alpha.tesla.com,2022,elon,T0pS3kr3t,value1,value2
70
+ omega.tesla.com,2023,elon,T0pS3kr3t2,value3,value4
71
+ ```
72
+
73
+ ```ruby
74
+ hosts = Herd::Host.from_csv("hosts.csv")
75
+ runner = Herd::Runner.new(hosts)
76
+ ...
77
+ ```
78
+
58
79
  ### Something more complex
59
80
 
60
81
  ```ruby
@@ -72,6 +93,68 @@ result = runner.exec do
72
93
  puts "Added new key for host #{h}"
73
94
  end
74
95
  end
96
+
97
+ # or even simpler
98
+ my_key2 = "ssh-ed25519 ..."
99
+
100
+ result = runner.exec do
101
+ authorized_keys_contains_exactly([my_key, my_key2])
102
+ end
103
+ ```
104
+
105
+ ### Files and directories
106
+
107
+ Following example takes file from the `./files/etc/sudoers.d/50-elon`
108
+ and copy content to the remote host with required permissions.
109
+
110
+ ```ruby
111
+ result = runner.exec do
112
+ file("/etc/sudoers.d/50-elon", "root", "root", 440)
113
+
114
+ # or copy dirs
115
+ dir("/home/elon/projects", "elon", "elon")
116
+ end
117
+ ```
118
+
119
+ ### Templates
120
+
121
+ Following example takes ERB template from the `./templates/home/elon/.env.erb`
122
+ and renders using additional `Herd::Host` values and copies content to the remote host
123
+ `/home/elon/.env`:
124
+
125
+ ```erb
126
+ # File: ./templates/home/elon/.env.erb
127
+ export ALIAS=<%= alias %>
128
+ ```
129
+
130
+ ```ruby
131
+ host = Herd::Host.new("tesla.com", "elon", password: "T0pS3kr3t", alias: "alpha001")
132
+ runner = Runner.new([host])
133
+ runner.exec do |values|
134
+ # values contain named arguments (except password and public_key_path)
135
+ # from the host constructor:
136
+ # { host: "tesla.com", port: 22, user: "elon", alias: "alpha001" }
137
+ template("/home/elon/.env", "elon", "wheels", values: values)
138
+ end
139
+ ```
140
+
141
+ ### Crontab
142
+
143
+ ```ruby
144
+ crontab("* * * * * /some-script.sh")
145
+ ```
146
+
147
+ ### Logs
148
+ Herd logs all commands, outputs and errors into the `log/<host>_<port>_<user>/<timestamp>.json` files:
149
+
150
+ ```json
151
+ {
152
+ {"vars":{"alias":"alpha001","port":22,"host":"tesla.com","user":"elon"}},
153
+ {"timestamp":"2025-11-09 18:10:21.134","command":"test -a /home/elon/.herd-version; echo $?"},
154
+ {"timestamp":"2025-11-09 18:10:21.395","command":"test -a /home/elon/.herd-version; echo $?","output":"4\r\n","time":0.261358},
155
+ {"timestamp":"2025-11-09 18:10:22.013","command":"cat /home/home/.herd-version"},
156
+ {"timestamp":"2025-11-09 18:10:22.314","command":"cat /home/home/.herd-version","output":"4\r\n","time":0.301}
157
+ }
75
158
  ```
76
159
 
77
160
  ## Development
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herd
4
+ module Commands
5
+ # Commands for inspecting and managing authorized SSH keys.
6
+ module AuthorizedKeys
7
+ AUTHORIZED_KEYS_FILE = "~/.ssh/authorized_keys"
8
+
9
+ def authorized_keys_contains_exactly(required_keys)
10
+ required_keys = [required_keys].flatten
11
+ diff = keys_diff(authorized_keys, required_keys)
12
+ self.authorized_keys = required_keys
13
+
14
+ diff
15
+ end
16
+
17
+ def authorized_keys
18
+ read_file(AUTHORIZED_KEYS_FILE)&.split(/\r\n|\r|\n/) || []
19
+ end
20
+
21
+ def authorized_keys=(keys)
22
+ touch(AUTHORIZED_KEYS_FILE)
23
+ write_to_file(AUTHORIZED_KEYS_FILE, [keys].flatten.join("\n"))
24
+ file_permissions(AUTHORIZED_KEYS_FILE, 600)
25
+ end
26
+
27
+ def add_authorized_key(key)
28
+ touch(AUTHORIZED_KEYS_FILE)
29
+ append_to_file(AUTHORIZED_KEYS_FILE, key)
30
+ file_permissions(AUTHORIZED_KEYS_FILE, 600)
31
+ end
32
+
33
+ private
34
+
35
+ def keys_diff(actual_keys, required_keys)
36
+ result = Hash.new { |h, k| h[k] = [] }
37
+ actual_keys.each do |actual_key|
38
+ if required_keys.include?(actual_key)
39
+ result[:existing] << actual_key
40
+ else
41
+ result[:obsolete] << actual_key
42
+ end
43
+ end
44
+ result.merge({ added: required_keys - actual_keys })
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herd
4
+ module Commands
5
+ # Commands for adding new crontab tasks or replace with given one.
6
+ module Crontab
7
+ def add_cron(entry)
8
+ run(%(crontab -l | { cat; echo "#{entry}"; } | crontab -))
9
+ end
10
+
11
+ def crontab(entry)
12
+ run(%(echo "#{entry}" | crontab -))
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "diff/lcs"
4
+ require "diff/lcs/hunk"
5
+
6
+ module Herd
7
+ module Commands
8
+ FILES = "files"
9
+
10
+ # Create, read, write, remove files, check permssions.
11
+ module Files
12
+ class PermissionDeniedError < StandardError; end
13
+
14
+ def file_exists?(path)
15
+ run("test -a #{path}; echo $?").chomp == "0"
16
+ end
17
+
18
+ def file_readable?(path)
19
+ run("test -r #{path}; echo $?").chomp == "0"
20
+ end
21
+
22
+ def file_writable?(path)
23
+ run("test -w #{path}; echo $?").chomp == "0"
24
+ end
25
+
26
+ def dir(path, user, group, sudo: false)
27
+ Dir.glob(File.join(FILES, path, "**/*"), File::FNM_DOTMATCH).each do |f|
28
+ remote_path = f.sub(FILES, "")
29
+ if File.directory?(f)
30
+ mkdir_p(remote_path, user, group, sudo: sudo)
31
+ else
32
+ file(remote_path, user, group)
33
+ end
34
+ end
35
+ end
36
+
37
+ def mkdir_p(path, user, group, sudo: false)
38
+ if sudo
39
+ run("sudo mkdir -p #{path}")
40
+ else
41
+ run("mkdir -p #{path}")
42
+ end
43
+ file_user_and_group(path, user, group)
44
+ end
45
+
46
+ def file(path, user, group, content: nil, mode: nil)
47
+ required_content = if content.nil?
48
+ File.read(File.join(FILES, path))
49
+ else
50
+ content
51
+ end
52
+
53
+ expect_file_content_equals(path, required_content)
54
+
55
+ file_user_and_group(path, user, group)
56
+ file_permissions(path, mode) if mode
57
+ end
58
+
59
+ def expect_file_content_equals(path, required_content)
60
+ if file_exists?(path)
61
+ actual_content = read_file!(path, sudo: true)
62
+ unless actual_content.lines(chomp: true) == required_content.lines(chomp: true)
63
+ # "File has been replaced with diff:\n\n#{diff(actual_content, required_content)}"
64
+ write_to_file!(path, required_content, sudo: true)
65
+ end
66
+ else
67
+ write_to_file!(path, required_content, sudo: true)
68
+ end
69
+ end
70
+
71
+ def read_file!(path, sudo: false)
72
+ if file_readable?(path)
73
+ read_file(path)
74
+ elsif sudo
75
+ read_file(path, sudo: true)
76
+ else
77
+ raise PermissionDeniedError, "'#{path}' is not readable"
78
+ end
79
+ end
80
+
81
+ def read_file(path, sudo: false)
82
+ command = "cat #{path}"
83
+ command = "sudo #{command}" if sudo
84
+
85
+ result = run(command)&.chomp
86
+ result = result.sub(/\A(\r\n|\r|\n)/, "") if sudo
87
+
88
+ result
89
+ end
90
+
91
+ def write_to_file!(path, content, sudo: false)
92
+ if file_writable?(path)
93
+ write_to_file(path, content)
94
+ elsif sudo
95
+ write_to_file(path, content, sudo: true)
96
+ else
97
+ raise PermissionDeniedError, "'#{path}' is not writable"
98
+ end
99
+ end
100
+
101
+ def write_to_file(path, content, sudo: false)
102
+ command = "tee"
103
+ command = "sudo #{command}" if sudo
104
+ run(%(#{command} #{path} << EOF
105
+ #{content}
106
+ EOF))
107
+ end
108
+
109
+ def append_to_file(path, content, sudo: false)
110
+ command = "tee -a"
111
+ command = "sudo #{command}" if sudo
112
+ run(%(#{command} #{path} << EOF
113
+ #{content}
114
+ EOF))
115
+ end
116
+
117
+ def file_user_and_group(path, user, group)
118
+ sudo("chown #{user}:#{group} #{path}")
119
+ end
120
+
121
+ def file_permissions(path, mode)
122
+ sudo("chmod #{mode} #{path}")
123
+ end
124
+
125
+ def diff(actual, required)
126
+ actual = actual.lines(chomp: true)
127
+ required = required.lines(chomp: true)
128
+
129
+ diffs = Diff::LCS.diff actual, required
130
+ Diff::LCS::Hunk.new(actual, required, diffs[0], 3, 0).diff(:unified)
131
+ end
132
+ end
133
+ end
134
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Herd
4
- module SessionCommands
4
+ module Commands
5
5
  # Working with package managers, like apt for Ubuntu
6
6
  module Packages
7
7
  def install_packages(packages)
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herd
4
+ module Commands
5
+ # Commands for python environment
6
+ module Python
7
+ def install_uv
8
+ run("curl -LsSf https://astral.sh/uv/install.sh | sh")
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Herd
6
+ module Commands
7
+ TEMPLATES = "templates"
8
+
9
+ # Render templates using ERB
10
+ module Templates
11
+ def template(path, user, group, mode: nil, values: {})
12
+ erb = ERB.new(File.read(File.join(TEMPLATES, "#{path}.erb")))
13
+ content = erb.result_with_hash(values)
14
+ file(path, user, group, mode: mode, content: content)
15
+ end
16
+ end
17
+ end
18
+ end
data/lib/herd/host.rb CHANGED
@@ -1,36 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "csv"
4
+ require "json"
3
5
  require "net/ssh"
4
6
 
5
7
  module Herd
6
8
  # Target host
7
9
  class Host
8
- attr_reader :host, :user, :ssh_options, :password
10
+ include Herd::Log
9
11
 
10
- def initialize(host, user, port: 22, private_key_path: nil, password: nil)
12
+ attr_reader :host, :user, :ssh_options, :password, :vars, :log
13
+
14
+ # port, private_key_path, password are for the ssh connection
15
+ def initialize(host, user, options)
11
16
  @host = host
12
17
  @user = user
13
18
 
14
- @ssh_options = { port: port, timeout: 10 }
15
- if private_key_path
16
- @ssh_options[:keys] = [private_key_path]
19
+ create_ssh_options(options)
20
+
21
+ @password = options.delete(:password)
22
+ @vars = options.merge(host: host, user: user, port: ssh_options[:port])
23
+
24
+ open_log
25
+ end
26
+
27
+ def create_ssh_options(options)
28
+ @ssh_options = { port: options[:port] || 22, timeout: 10 }
29
+ if options[:private_key_path]
30
+ @ssh_options[:keys] = [options.delete(:private_key_path)]
17
31
  else
18
- @ssh_options[:password] = password
32
+ @ssh_options[:password] = options[:password]
19
33
  end
20
-
21
- @password = password
22
34
  end
23
35
 
24
36
  def exec(command = nil, &)
25
37
  Net::SSH.start(host, user, ssh_options) do |ssh|
26
- session = Herd::Session.new(ssh, password)
38
+ session = Herd::Session.new(ssh, password, log)
27
39
 
28
- output = nil
29
40
  output = session.send(command) if command
30
- output = session.instance_exec(&) if block_given?
31
-
41
+ output = session.instance_exec(vars, &) if block_given?
32
42
  output
33
43
  end
44
+ rescue StandardError => e
45
+ log_connection_error(e)
46
+ ensure
47
+ close_log
48
+ end
49
+
50
+ def self.from_csv(file = "hosts.csv")
51
+ CSV.read(file, headers: true).map do |csv|
52
+ h = csv.to_h.transform_keys(&:to_sym)
53
+ host = h.delete(:host)
54
+ user = h.delete(:user)
55
+ port = h.delete(:port).to_i
56
+ new(host, user, h.merge(port: port))
57
+ end
34
58
  end
35
59
  end
36
60
  end
data/lib/herd/log.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Herd
7
+ # Methods for logging commands, outputs and errors.
8
+ module Log
9
+ def log_file_path
10
+ dir = "log/#{vars[:host]}_#{vars[:port]}_#{vars[:user]}"
11
+ FileUtils.mkdir_p(dir)
12
+ "#{File.join(dir, Time.now.strftime("%Y%m%d_%H%M%S"))}.json"
13
+ end
14
+
15
+ def open_log
16
+ @log = File.open(log_file_path, "w")
17
+
18
+ log.puts "{"
19
+ log.print({ vars: vars }.to_json)
20
+ end
21
+
22
+ def close_log
23
+ log.puts "\n}"
24
+ log.close
25
+ end
26
+
27
+ def log_connection_error(error)
28
+ puts "#{vars.inspect}: #{error.message}"
29
+ log.puts ","
30
+ log.print({ error: error.message, error_trace: error.backtrace }.to_json)
31
+ end
32
+
33
+ def log_command_start(timestamp, command)
34
+ log.puts(",")
35
+ log.print({ timestamp: time(timestamp), command: command }.to_json)
36
+ end
37
+
38
+ def log_command_output(command, output, started_at)
39
+ now = Time.now
40
+ log.puts(",")
41
+ log.print({ timestamp: time(now), command: command, output: output, time: now - started_at }.to_json)
42
+ end
43
+
44
+ def log_command_error(command, error, started_at)
45
+ now = Time.now
46
+ log.puts(",")
47
+ log.print({ timestamp: time(now), command: command, error: error, time: now - started_at }.to_json)
48
+ end
49
+
50
+ def time(timestamp = Time.now)
51
+ timestamp.strftime("%Y-%m-%d %H:%M:%S.%L")
52
+ end
53
+ end
54
+ end
data/lib/herd/session.rb CHANGED
@@ -3,14 +3,17 @@
3
3
  module Herd
4
4
  # Session for executing commands on the remote host
5
5
  class Session
6
+ include Herd::Log
7
+
6
8
  OS_COMMANDS = %i[cat chmod echo hostname touch].freeze
7
- CUSTOM_COMMANDS_DIR = File.expand_path("session/commands", __dir__)
9
+ CUSTOM_COMMANDS_DIR = File.expand_path("commands", __dir__)
8
10
 
9
- attr_reader :ssh, :password
11
+ attr_reader :ssh, :password, :log
10
12
 
11
- def initialize(ssh, password = nil)
13
+ def initialize(ssh, password, log)
12
14
  @ssh = ssh
13
15
  @password = password
16
+ @log = log
14
17
  end
15
18
 
16
19
  def method_missing(cmd, *args)
@@ -27,7 +30,7 @@ module Herd
27
30
  channel.request_pty do |ch, success|
28
31
  raise ::Herd::CommandError, "could not obtain pty" unless success
29
32
 
30
- channel_run(ch, command, result)
33
+ channel_run(ch, command, result, Time.now)
31
34
  end
32
35
  end
33
36
  ssh.loop
@@ -42,7 +45,7 @@ module Herd
42
45
  def load_command_modules
43
46
  command_files.each { |file| require file }
44
47
 
45
- session_command_modules.each do |mod|
48
+ command_modules.each do |mod|
46
49
  next if self <= mod
47
50
 
48
51
  prepend mod
@@ -55,29 +58,45 @@ module Herd
55
58
  Dir[File.join(CUSTOM_COMMANDS_DIR, "*.rb")]
56
59
  end
57
60
 
58
- def session_command_modules
59
- return [] unless defined?(Herd::SessionCommands)
61
+ def command_modules
62
+ return [] unless defined?(Herd::Commands)
60
63
 
61
- Herd::SessionCommands.constants
62
- .sort
63
- .map { |const_name| Herd::SessionCommands.const_get(const_name) }
64
- .select { |value| value.is_a?(Module) }
64
+ Herd::Commands.constants
65
+ .sort
66
+ .map { |const_name| Herd::Commands.const_get(const_name) }
67
+ .select { |value| value.is_a?(Module) }
65
68
  end
66
69
  end
67
70
 
68
71
  private
69
72
 
70
- def channel_run(channel, command, result)
73
+ def channel_run(channel, command, result, started_at)
74
+ log_command_start(started_at, command)
75
+
71
76
  channel.exec(command) do |c, _|
72
77
  c.on_data do |_, data|
73
- result << data
78
+ process_output(c, command, started_at, data, result)
74
79
  end
75
80
 
76
81
  c.on_extended_data do |_, _, data|
77
- raise ::Herd::CommandError, data
82
+ process_error(command, started_at, data)
78
83
  end
79
84
  end
80
85
  end
86
+
87
+ def process_output(channel, command, started_at, data, result)
88
+ if data.include?("[sudo] password for")
89
+ channel.send_data "#{password}\n"
90
+ else
91
+ log_command_output(command, data, started_at)
92
+ result << data
93
+ end
94
+ end
95
+
96
+ def process_error(command, started_at, data)
97
+ log_command_error(command, data, started_at)
98
+ raise ::Herd::CommandError, data
99
+ end
81
100
  end
82
101
  end
83
102
 
data/lib/herd/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Herd
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/herd.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "herd/version"
4
+ require_relative "herd/log"
4
5
  require_relative "herd/host"
5
6
  require_relative "herd/runner"
6
7
  require_relative "herd/session"
data/log/.keep ADDED
File without changes
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: herd-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yury Kotlyarov
@@ -25,12 +25,18 @@ files:
25
25
  - Rakefile
26
26
  - TODO.md
27
27
  - lib/herd.rb
28
+ - lib/herd/commands/authorized_keys.rb
29
+ - lib/herd/commands/crontab.rb
30
+ - lib/herd/commands/files.rb
31
+ - lib/herd/commands/packages.rb
32
+ - lib/herd/commands/python.rb
33
+ - lib/herd/commands/templates.rb
28
34
  - lib/herd/host.rb
35
+ - lib/herd/log.rb
29
36
  - lib/herd/runner.rb
30
37
  - lib/herd/session.rb
31
- - lib/herd/session/commands/authorized_keys.rb
32
- - lib/herd/session/commands/packages.rb
33
38
  - lib/herd/version.rb
39
+ - log/.keep
34
40
  - sig/herd.rbs
35
41
  homepage: https://github.com/yura/herd
36
42
  licenses:
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Herd
4
- module SessionCommands
5
- # Commands for inspecting and managing authorized SSH keys.
6
- module AuthorizedKeys
7
- def authorized_keys
8
- cat("~/.ssh/authorized_keys")&.chomp&.split("\n") || []
9
- end
10
-
11
- def add_authorized_key(key)
12
- touch("~/.ssh/authorized_keys")
13
- chmod("600 ~/.ssh/authorized_keys")
14
- echo("'#{key}' >> ~/.ssh/authorized_keys")
15
- end
16
- end
17
- end
18
- end