herd-rb 0.2.0 → 0.4.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 +4 -4
- data/.ruby-version +1 -0
- data/README.md +92 -3
- data/TODO.md +20 -0
- data/lib/herd/commands/authorized_keys.rb +48 -0
- data/lib/herd/commands/crontab.rb +16 -0
- data/lib/herd/commands/files.rb +134 -0
- data/lib/herd/commands/packages.rb +13 -0
- data/lib/herd/commands/python.rb +12 -0
- data/lib/herd/commands/templates.rb +18 -0
- data/lib/herd/host.rb +37 -10
- data/lib/herd/log.rb +54 -0
- data/lib/herd/runner.rb +2 -2
- data/lib/herd/session.rb +81 -17
- data/lib/herd/version.rb +1 -1
- data/lib/herd.rb +1 -0
- data/log/.keep +0 -0
- metadata +14 -59
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad0f13503109d91f099f4b40a1c208bafd460fd6efbcfe8d15cb92c3d0bad66b
|
|
4
|
+
data.tar.gz: 2a793b3ff5246eabb9ea55cd67b507786b1226afc40ace46208737176f0bd4df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2a2b263aec47c102e8d60069352b46814032a90b546928bb5a7cb4ed5ee2cae93cfc05e2b153e71a0366249c4e59a8a84903a5f3e6c7efd34de1a2d7771023b0
|
|
7
|
+
data.tar.gz: 02e6e5e6bd999632457f7521ae443fa55847d8ef9d8a563cc01b5fcf5a9a3f033064e06b869b36962170ba134006decee2e104f73e9dbaeae863d35715b4d2e7
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.4.2
|
data/README.md
CHANGED
|
@@ -4,9 +4,15 @@ Fast host configuration tool.
|
|
|
4
4
|
|
|
5
5
|
## TODO
|
|
6
6
|
|
|
7
|
-
* [
|
|
8
|
-
* [
|
|
9
|
-
* [
|
|
7
|
+
* [x] Run with `sudo`
|
|
8
|
+
* [x] Commands with arguments
|
|
9
|
+
* [x] Reading and writing files
|
|
10
|
+
* [x] Templates (ERB)
|
|
11
|
+
* [x] Copy dirs
|
|
12
|
+
* [ ] Compare with Rsync
|
|
13
|
+
* [x] Crontab
|
|
14
|
+
* [ ] Log all commands for all hosts
|
|
15
|
+
* [ ] Add user to group
|
|
10
16
|
|
|
11
17
|
## Installation
|
|
12
18
|
|
|
@@ -55,6 +61,89 @@ 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
|
+
|
|
79
|
+
### Something more complex
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
public_key_path = File.expand_path("~/.ssh/id_ed25519.pub")
|
|
83
|
+
my_key = File.read(public_key_path).chomp
|
|
84
|
+
|
|
85
|
+
result = runner.exec do
|
|
86
|
+
h = hostname
|
|
87
|
+
keys = authorized_keys
|
|
88
|
+
|
|
89
|
+
if keys.include?(my_key)
|
|
90
|
+
puts "Key already in authorized_keys on host #{h}"
|
|
91
|
+
else
|
|
92
|
+
add_authorized_key my_key
|
|
93
|
+
puts "Added new key for host #{h}"
|
|
94
|
+
end
|
|
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
|
+
|
|
58
147
|
## Development
|
|
59
148
|
|
|
60
149
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
data/TODO.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# TODO
|
|
2
|
+
|
|
3
|
+
- [x] Load session commands automatically from `lib/herd/session/commands`.
|
|
4
|
+
- [x] Extract existing `Session` command helpers (e.g., `add_authorized_key`) into the commands directory.
|
|
5
|
+
- [x] Ensure new command loading approach is covered by specs.
|
|
6
|
+
- [ ] Add explicit `logger` dependency to address net-ssh warning on Ruby 3.4.2.
|
|
7
|
+
- [ ] Document new command-extension flow with YARD annotations.
|
|
8
|
+
|
|
9
|
+
# Log
|
|
10
|
+
|
|
11
|
+
- Initialized planning file and seeded initial task list.
|
|
12
|
+
- Updated project configuration to target Ruby 3.4.2 (Gemfile, gemspec, .ruby-version, lockfile).
|
|
13
|
+
- Synced RuboCop config with Ruby 3.4.2 and confirmed clean lint run.
|
|
14
|
+
- Moved development dependencies from gemspec to Gemfile; bundle, lint, and tests still pass (with net-ssh logger warning).
|
|
15
|
+
- Fixed `Session#method_missing` to avoid extra spaces when building commands; specs now green.
|
|
16
|
+
- Implemented automatic session command loading via prepended modules, moved authorized key helpers, added YARD dependency, and expanded specs accordingly.
|
|
17
|
+
|
|
18
|
+
# Notes
|
|
19
|
+
|
|
20
|
+
- Keep command implementations in English and back them with tests.
|
|
@@ -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
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herd
|
|
4
|
+
module Commands
|
|
5
|
+
# Working with package managers, like apt for Ubuntu
|
|
6
|
+
module Packages
|
|
7
|
+
def install_packages(packages)
|
|
8
|
+
packages = [packages].flatten.join(" ")
|
|
9
|
+
echo %(-e '#{password}\n' | sudo -S apt install -qq -y #{packages})
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
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,33 +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
|
-
|
|
10
|
+
include Herd::Log
|
|
9
11
|
|
|
10
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
34
|
end
|
|
21
35
|
|
|
22
|
-
def exec(command = nil, &
|
|
36
|
+
def exec(command = nil, &)
|
|
23
37
|
Net::SSH.start(host, user, ssh_options) do |ssh|
|
|
24
|
-
session = Herd::Session.new(ssh)
|
|
38
|
+
session = Herd::Session.new(ssh, password, log)
|
|
25
39
|
|
|
26
|
-
output = nil
|
|
27
40
|
output = session.send(command) if command
|
|
28
|
-
output = session.instance_exec(&
|
|
41
|
+
output = session.instance_exec(vars, &) if block_given?
|
|
29
42
|
output
|
|
30
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
|
|
31
58
|
end
|
|
32
59
|
end
|
|
33
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/runner.rb
CHANGED
data/lib/herd/session.rb
CHANGED
|
@@ -3,37 +3,101 @@
|
|
|
3
3
|
module Herd
|
|
4
4
|
# Session for executing commands on the remote host
|
|
5
5
|
class Session
|
|
6
|
-
|
|
6
|
+
include Herd::Log
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
OS_COMMANDS = %i[cat chmod echo hostname touch].freeze
|
|
9
|
+
CUSTOM_COMMANDS_DIR = File.expand_path("commands", __dir__)
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
attr_reader :ssh, :password, :log
|
|
12
|
+
|
|
13
|
+
def initialize(ssh, password, log)
|
|
11
14
|
@ssh = ssh
|
|
15
|
+
@password = password
|
|
16
|
+
@log = log
|
|
12
17
|
end
|
|
13
18
|
|
|
14
|
-
def
|
|
15
|
-
|
|
19
|
+
def method_missing(cmd, *args)
|
|
20
|
+
command_parts = [cmd.to_s]
|
|
21
|
+
command_parts.concat(args.map(&:to_s)) if args.any?
|
|
22
|
+
command = command_parts.join(" ")
|
|
23
|
+
|
|
24
|
+
run(command)
|
|
16
25
|
end
|
|
17
26
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
def run(command)
|
|
28
|
+
result = []
|
|
29
|
+
ssh.open_channel do |channel|
|
|
30
|
+
channel.request_pty do |ch, success|
|
|
31
|
+
raise ::Herd::CommandError, "could not obtain pty" unless success
|
|
32
|
+
|
|
33
|
+
channel_run(ch, command, result, Time.now)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
ssh.loop
|
|
37
|
+
result.join
|
|
22
38
|
end
|
|
23
39
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
def respond_to_missing?(cmd)
|
|
41
|
+
OS_COMMANDS.include?(cmd) || super
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
def load_command_modules
|
|
46
|
+
command_files.each { |file| require file }
|
|
47
|
+
|
|
48
|
+
command_modules.each do |mod|
|
|
49
|
+
next if self <= mod
|
|
50
|
+
|
|
51
|
+
prepend mod
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
27
56
|
|
|
28
|
-
|
|
29
|
-
|
|
57
|
+
def command_files
|
|
58
|
+
Dir[File.join(CUSTOM_COMMANDS_DIR, "*.rb")]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def command_modules
|
|
62
|
+
return [] unless defined?(Herd::Commands)
|
|
30
63
|
|
|
31
|
-
|
|
64
|
+
Herd::Commands.constants
|
|
65
|
+
.sort
|
|
66
|
+
.map { |const_name| Herd::Commands.const_get(const_name) }
|
|
67
|
+
.select { |value| value.is_a?(Module) }
|
|
32
68
|
end
|
|
33
69
|
end
|
|
34
70
|
|
|
35
|
-
|
|
36
|
-
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def channel_run(channel, command, result, started_at)
|
|
74
|
+
log_command_start(started_at, command)
|
|
75
|
+
|
|
76
|
+
channel.exec(command) do |c, _|
|
|
77
|
+
c.on_data do |_, data|
|
|
78
|
+
process_output(c, command, started_at, data, result)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
c.on_extended_data do |_, _, data|
|
|
82
|
+
process_error(command, started_at, data)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
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
|
|
37
99
|
end
|
|
38
100
|
end
|
|
39
101
|
end
|
|
102
|
+
|
|
103
|
+
Herd::Session.load_command_modules
|
data/lib/herd/version.rb
CHANGED
data/lib/herd.rb
CHANGED
data/log/.keep
ADDED
|
File without changes
|
metadata
CHANGED
|
@@ -1,70 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: herd-rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yury Kotlyarov
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: rspec
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - "~>"
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: '3.0'
|
|
19
|
-
type: :development
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - "~>"
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: '3.0'
|
|
26
|
-
- !ruby/object:Gem::Dependency
|
|
27
|
-
name: rubocop
|
|
28
|
-
requirement: !ruby/object:Gem::Requirement
|
|
29
|
-
requirements:
|
|
30
|
-
- - "~>"
|
|
31
|
-
- !ruby/object:Gem::Version
|
|
32
|
-
version: '1.21'
|
|
33
|
-
type: :development
|
|
34
|
-
prerelease: false
|
|
35
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
-
requirements:
|
|
37
|
-
- - "~>"
|
|
38
|
-
- !ruby/object:Gem::Version
|
|
39
|
-
version: '1.21'
|
|
40
|
-
- !ruby/object:Gem::Dependency
|
|
41
|
-
name: rubocop-rake
|
|
42
|
-
requirement: !ruby/object:Gem::Requirement
|
|
43
|
-
requirements:
|
|
44
|
-
- - ">="
|
|
45
|
-
- !ruby/object:Gem::Version
|
|
46
|
-
version: '0'
|
|
47
|
-
type: :development
|
|
48
|
-
prerelease: false
|
|
49
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
-
requirements:
|
|
51
|
-
- - ">="
|
|
52
|
-
- !ruby/object:Gem::Version
|
|
53
|
-
version: '0'
|
|
54
|
-
- !ruby/object:Gem::Dependency
|
|
55
|
-
name: rubocop-rspec
|
|
56
|
-
requirement: !ruby/object:Gem::Requirement
|
|
57
|
-
requirements:
|
|
58
|
-
- - ">="
|
|
59
|
-
- !ruby/object:Gem::Version
|
|
60
|
-
version: '0'
|
|
61
|
-
type: :development
|
|
62
|
-
prerelease: false
|
|
63
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
-
requirements:
|
|
65
|
-
- - ">="
|
|
66
|
-
- !ruby/object:Gem::Version
|
|
67
|
-
version: '0'
|
|
11
|
+
dependencies: []
|
|
68
12
|
description: |
|
|
69
13
|
Simple ruby DSL for fast host configuration. Supports Ubuntu and requires
|
|
70
14
|
SSH server running on each targets host.
|
|
@@ -74,15 +18,25 @@ executables: []
|
|
|
74
18
|
extensions: []
|
|
75
19
|
extra_rdoc_files: []
|
|
76
20
|
files:
|
|
21
|
+
- ".ruby-version"
|
|
77
22
|
- CODE_OF_CONDUCT.md
|
|
78
23
|
- LICENSE.txt
|
|
79
24
|
- README.md
|
|
80
25
|
- Rakefile
|
|
26
|
+
- TODO.md
|
|
81
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
|
|
82
34
|
- lib/herd/host.rb
|
|
35
|
+
- lib/herd/log.rb
|
|
83
36
|
- lib/herd/runner.rb
|
|
84
37
|
- lib/herd/session.rb
|
|
85
38
|
- lib/herd/version.rb
|
|
39
|
+
- log/.keep
|
|
86
40
|
- sig/herd.rbs
|
|
87
41
|
homepage: https://github.com/yura/herd
|
|
88
42
|
licenses:
|
|
@@ -91,6 +45,7 @@ metadata:
|
|
|
91
45
|
allowed_push_host: https://rubygems.org
|
|
92
46
|
homepage_uri: https://github.com/yura/herd
|
|
93
47
|
source_code_uri: https://github.com/yura/herd
|
|
48
|
+
rubygems_mfa_required: 'true'
|
|
94
49
|
rdoc_options: []
|
|
95
50
|
require_paths:
|
|
96
51
|
- lib
|
|
@@ -98,7 +53,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
98
53
|
requirements:
|
|
99
54
|
- - ">="
|
|
100
55
|
- !ruby/object:Gem::Version
|
|
101
|
-
version: 3.2
|
|
56
|
+
version: 3.4.2
|
|
102
57
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
103
58
|
requirements:
|
|
104
59
|
- - ">="
|