opswalrus 1.0.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 +7 -0
- data/.rspec +3 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +59 -0
- data/LICENSE +674 -0
- data/README.md +256 -0
- data/Rakefile +8 -0
- data/exe/ops +6 -0
- data/exe/run_ops_bundle +22 -0
- data/lib/opswalrus/app.rb +328 -0
- data/lib/opswalrus/bootstrap.sh +57 -0
- data/lib/opswalrus/bootstrap_linux_host1.sh +12 -0
- data/lib/opswalrus/bootstrap_linux_host2.sh +37 -0
- data/lib/opswalrus/bootstrap_linux_host3.sh +21 -0
- data/lib/opswalrus/bundler.rb +175 -0
- data/lib/opswalrus/cli.rb +143 -0
- data/lib/opswalrus/host.rb +177 -0
- data/lib/opswalrus/hosts_file.rb +55 -0
- data/lib/opswalrus/interaction_handlers.rb +53 -0
- data/lib/opswalrus/local_non_blocking_backend.rb +132 -0
- data/lib/opswalrus/local_pty_backend.rb +89 -0
- data/lib/opswalrus/operation_runner.rb +85 -0
- data/lib/opswalrus/ops_file.rb +235 -0
- data/lib/opswalrus/ops_file_script.rb +472 -0
- data/lib/opswalrus/package_file.rb +102 -0
- data/lib/opswalrus/runtime_environment.rb +258 -0
- data/lib/opswalrus/sshkit_ext.rb +51 -0
- data/lib/opswalrus/traversable.rb +15 -0
- data/lib/opswalrus/version.rb +3 -0
- data/lib/opswalrus/walrus_lang.rb +83 -0
- data/lib/opswalrus/zip.rb +57 -0
- data/lib/opswalrus.rb +10 -0
- data/opswalrus.gemspec +45 -0
- data/sig/opswalrus.rbs +4 -0
- metadata +178 -0
@@ -0,0 +1,37 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
# there are probably some services that need restarting because they're using old libraries, so we'll just do the easy thing and reboot
|
4
|
+
sudo DEBIAN_FRONTEND=noninteractive apt install -yq needrestart
|
5
|
+
|
6
|
+
# install basic development tools
|
7
|
+
sudo DEBIAN_FRONTEND=noninteractive apt install -yq build-essential
|
8
|
+
|
9
|
+
# install ruby dependencies
|
10
|
+
sudo DEBIAN_FRONTEND=noninteractive apt install -yq autoconf patch rustc libssl-dev libyaml-dev libreadline6-dev zlib1g-dev libgmp-dev libncurses5-dev libffi-dev libgdbm6 libgdbm-dev libdb-dev uuid-dev
|
11
|
+
|
12
|
+
# restart services that need it
|
13
|
+
sudo needrestart -q -r a
|
14
|
+
|
15
|
+
# vagrant@ubuntu-jammy:~$ sudo needrestart -q -r a
|
16
|
+
# systemctl restart unattended-upgrades.service
|
17
|
+
|
18
|
+
# vagrant@ubuntu-jammy:~$ sudo needrestart -r l
|
19
|
+
# Scanning processes...
|
20
|
+
# Scanning candidates...
|
21
|
+
# Scanning linux images...
|
22
|
+
#
|
23
|
+
# Running kernel seems to be up-to-date.
|
24
|
+
#
|
25
|
+
# Services to be restarted:
|
26
|
+
#
|
27
|
+
# Service restarts being deferred:
|
28
|
+
# systemctl restart unattended-upgrades.service
|
29
|
+
#
|
30
|
+
# No containers need to be restarted.
|
31
|
+
#
|
32
|
+
# No user sessions are running outdated binaries.
|
33
|
+
#
|
34
|
+
# No VM guests are running outdated hypervisor (qemu) binaries on this host.
|
35
|
+
|
36
|
+
# reboot just in case
|
37
|
+
# sudo reboot
|
@@ -0,0 +1,21 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
# install homebrew
|
4
|
+
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
5
|
+
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
|
6
|
+
|
7
|
+
# install gcc, rust, ruby
|
8
|
+
brew install gcc
|
9
|
+
brew install rust
|
10
|
+
brew install ruby
|
11
|
+
|
12
|
+
# brew install rtx
|
13
|
+
# eval "$(rtx activate bash)" # register a shell hook
|
14
|
+
# rtx use -g ruby@3.2 # install ruby via rtx
|
15
|
+
|
16
|
+
# download frum for ruby version management
|
17
|
+
# curl -L -o frum.tar.gz https://github.com/TaKO8Ki/frum/releases/download/v0.1.2/frum-v0.1.2-x86_64-unknown-linux-musl.tar.gz
|
18
|
+
# tar -zxf frum.tar.gz
|
19
|
+
# mv frum-v0.1.2-x86_64-unknown-linux-musl/frum ~/bin
|
20
|
+
# chmod 755 ~/bin/frum
|
21
|
+
|
@@ -0,0 +1,175 @@
|
|
1
|
+
require 'git'
|
2
|
+
require 'set'
|
3
|
+
|
4
|
+
require_relative "package_file"
|
5
|
+
require_relative "traversable"
|
6
|
+
require_relative "zip"
|
7
|
+
|
8
|
+
module OpsWalrus
|
9
|
+
class Bundler
|
10
|
+
BUNDLE_DIR = "opswalrus_bundle"
|
11
|
+
|
12
|
+
include Traversable
|
13
|
+
|
14
|
+
attr_accessor :pwd
|
15
|
+
|
16
|
+
def initialize(working_directory_path)
|
17
|
+
@pwd = working_directory_path.to_pathname
|
18
|
+
@bundle_dir = @pwd.join(BUNDLE_DIR)
|
19
|
+
end
|
20
|
+
|
21
|
+
def bundle_dir
|
22
|
+
@bundle_dir
|
23
|
+
end
|
24
|
+
|
25
|
+
def ensure_package_bundle_directory_exists
|
26
|
+
FileUtils.mkdir_p(@bundle_dir)
|
27
|
+
end
|
28
|
+
|
29
|
+
# # returns the OpsFile within the bundle directory that represents the given ops_file (which is outside of the bundle directory)
|
30
|
+
# def build_bundle_for_ops_file(ops_file)
|
31
|
+
# if ops_file.package_file # ops_file is part of larger package
|
32
|
+
# self_pkg_dir = build_bundle_for_package(ops_file.package_file)
|
33
|
+
# relative_ops_file_path = ops_file.ops_file_path.relative_path_from(ops_file.package_file.dirname)
|
34
|
+
# else
|
35
|
+
# # ops_file is not part of a larger package
|
36
|
+
# # in this case, we want to bundle the ops_file.dirname into the bundle directory
|
37
|
+
# update
|
38
|
+
# self_pkg_dir = include_directory_in_bundle_as_self_pkg(ops_file.dirname)
|
39
|
+
# relative_ops_file_path = ops_file.ops_file_path.relative_path_from(ops_file.dirname)
|
40
|
+
# end
|
41
|
+
# ops_file_in_self_pkg = self_pkg_dir.join(relative_ops_file_path)
|
42
|
+
# end
|
43
|
+
|
44
|
+
# # we want to bundle the package represented by package_file into the bundle directory that
|
45
|
+
# # resides within the package directory that contains the package_file
|
46
|
+
# def build_bundle_for_package(package_file)
|
47
|
+
# bundler_for_package = Bundler.new(package_file.dirname)
|
48
|
+
# bundler_for_package.update
|
49
|
+
# bundler_for_package.include_directory_in_bundle_as_self_pkg(pwd)
|
50
|
+
# end
|
51
|
+
|
52
|
+
def update
|
53
|
+
ensure_package_bundle_directory_exists
|
54
|
+
|
55
|
+
package_yaml_files = pwd.glob("./**/package.yaml") - pwd.glob("./**/#{BUNDLE_DIR}/**/package.yaml")
|
56
|
+
package_files_within_pwd = package_yaml_files.map {|path| PackageFile.new(path.realpath) }
|
57
|
+
|
58
|
+
download_dependency_tree(*package_files_within_pwd)
|
59
|
+
end
|
60
|
+
|
61
|
+
# downloads all transitive package dependencies associated with ops_files
|
62
|
+
# all downloaded packages are placed into @bundle_dir
|
63
|
+
def download_dependency_tree(*ops_files_and_package_files)
|
64
|
+
package_files = ops_files_and_package_files.map(&:package_file).compact.uniq
|
65
|
+
|
66
|
+
package_files.each do |root_package_file|
|
67
|
+
pre_order_traverse(root_package_file) do |package_file|
|
68
|
+
download_package_dependencies(package_file).map do |downloaded_package_directory_path|
|
69
|
+
package_file_path = File.join(downloaded_package_directory_path, "package.yaml")
|
70
|
+
PackageFile.new(package_file_path)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# returns the array of the destination directories that the packages that ops_file depend on were downloaded to
|
77
|
+
# e.g. [dir_path1, dir_path2, dir_path3, ...]
|
78
|
+
def download_package_dependencies(package_file)
|
79
|
+
package_file.dependencies.map do |local_name, package_reference|
|
80
|
+
download_package(package_file, package_reference)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# returns the self_pkg directory within the bundle directory
|
85
|
+
# def include_directory_in_bundle_as_self_pkg(dirname = pwd)
|
86
|
+
# ensure_package_bundle_directory_exists
|
87
|
+
|
88
|
+
# destination_package_path = @bundle_dir.join("self_pkg")
|
89
|
+
|
90
|
+
# # recreate the destination package path - self_pkg
|
91
|
+
# FileUtils.remove_dir(destination_package_path) if destination_package_path.exist?
|
92
|
+
# FileUtils.mkdir_p(destination_package_path)
|
93
|
+
|
94
|
+
# # files in dirname except for the BUNDLE_DIR
|
95
|
+
# files = dirname.glob("*").reject {|f| f.basename.to_s == BUNDLE_DIR }
|
96
|
+
# files.each do |file|
|
97
|
+
# FileUtils.cp_r(file, destination_package_path)
|
98
|
+
# end
|
99
|
+
|
100
|
+
# destination_package_path
|
101
|
+
# end
|
102
|
+
|
103
|
+
# This method downloads a package_url that is a dependency referenced in the specified package_file
|
104
|
+
# returns the destination directory that the package was downloaded to
|
105
|
+
def download_package(package_file, package_reference)
|
106
|
+
ensure_package_bundle_directory_exists
|
107
|
+
|
108
|
+
local_name = package_reference.local_name
|
109
|
+
package_url = package_reference.package_uri
|
110
|
+
version = package_reference.version
|
111
|
+
|
112
|
+
destination_package_path = @bundle_dir.join(package_reference.dirname)
|
113
|
+
FileUtils.remove_dir(destination_package_path) if destination_package_path.exist?
|
114
|
+
|
115
|
+
case
|
116
|
+
when package_url =~ /\.git/ # git reference
|
117
|
+
download_git_package(package_url, version, destination_package_path)
|
118
|
+
when package_url.start_with?("file://") # local path
|
119
|
+
path = package_url.sub("file://", "")
|
120
|
+
path = path.to_pathname
|
121
|
+
package_path_to_download = if path.relative? # relative path
|
122
|
+
package_file.containing_directory.join(path)
|
123
|
+
else # absolute path
|
124
|
+
path.realpath
|
125
|
+
end
|
126
|
+
|
127
|
+
raise Error, "Package not found: #{package_path_to_download}" unless package_path_to_download.exist?
|
128
|
+
FileUtils.cp_r(package_path_to_download, destination_package_path)
|
129
|
+
when package_url.to_pathname.exist? || package_file.containing_directory.join(package_url).exist? # local path
|
130
|
+
path = package_url.to_pathname
|
131
|
+
package_path_to_download = if path.relative? # relative path
|
132
|
+
package_file.containing_directory.join(path)
|
133
|
+
else # absolute path
|
134
|
+
path.realpath
|
135
|
+
end
|
136
|
+
|
137
|
+
raise Error, "Package not found: #{package_path_to_download}" unless File.exist?(package_path_to_download)
|
138
|
+
FileUtils.cp_r(package_path_to_download, destination_package_path)
|
139
|
+
else # git reference
|
140
|
+
download_git_package(package_url, version, destination_package_path)
|
141
|
+
end
|
142
|
+
|
143
|
+
destination_package_path
|
144
|
+
end
|
145
|
+
|
146
|
+
def download_git_package(package_url, version = nil, destination_package_path = nil)
|
147
|
+
destination_package_path ||= begin
|
148
|
+
package_reference_dirname = sanitize_path(package_url)
|
149
|
+
bundle_dir.join(package_reference_dirname)
|
150
|
+
end
|
151
|
+
FileUtils.remove_dir(destination_package_path) if File.exist?(destination_package_path)
|
152
|
+
if version
|
153
|
+
Git.clone(package_url, destination_package_path, branch: version, config: ['submodule.recurse=true'])
|
154
|
+
else
|
155
|
+
Git.clone(package_url, destination_package_path, config: ['submodule.recurse=true'])
|
156
|
+
end
|
157
|
+
destination_package_path
|
158
|
+
end
|
159
|
+
|
160
|
+
def sanitize_path(path)
|
161
|
+
# found this at https://apidock.com/rails/v5.2.3/ActiveStorage/Filename/sanitized
|
162
|
+
path.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�").strip.tr("\u{202E}%$|:;/\t\r\n\\", "-")
|
163
|
+
end
|
164
|
+
|
165
|
+
# returns the directory that the zip file is unzipped into
|
166
|
+
def unzip(zip_bundle_file, output_dir = nil)
|
167
|
+
if zip_bundle_file.to_pathname.exist?
|
168
|
+
output_dir ||= Dir.mktmpdir.to_pathname
|
169
|
+
|
170
|
+
# unzip the bundle into the output_dir directory
|
171
|
+
DirZipper.unzip(zip_bundle_file, output_dir)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
require "gli"
|
2
|
+
|
3
|
+
# require_relative "walrus_lang"
|
4
|
+
require_relative "app"
|
5
|
+
|
6
|
+
module OpsWalrus
|
7
|
+
class Cli
|
8
|
+
extend GLI::App
|
9
|
+
|
10
|
+
pre do |global_options, command, options, args|
|
11
|
+
$app = App.instance(Dir.pwd)
|
12
|
+
$app.set_local_hostname(ENV["WALRUS_LOCAL_HOSTNAME"]) if ENV["WALRUS_LOCAL_HOSTNAME"]
|
13
|
+
true
|
14
|
+
end
|
15
|
+
|
16
|
+
# this is invoked on an unhandled exception or a call to exit_now!
|
17
|
+
on_error do |exception|
|
18
|
+
puts "*" * 80
|
19
|
+
puts "catchall exception handler:"
|
20
|
+
puts exception.message
|
21
|
+
puts exception.backtrace.join("\n")
|
22
|
+
false # disable built-in exception handling
|
23
|
+
end
|
24
|
+
|
25
|
+
program_desc 'ops is an operation runner'
|
26
|
+
|
27
|
+
desc 'Be verbose'
|
28
|
+
switch [:v, :verbose]
|
29
|
+
|
30
|
+
desc 'Debug'
|
31
|
+
switch [:d, :debug]
|
32
|
+
|
33
|
+
flag [:h, :hosts], multiple: true, desc: "Specify the hosts.yaml file"
|
34
|
+
flag [:t, :tags], multiple: true, desc: "Specify a set of tags to filter the hosts by"
|
35
|
+
|
36
|
+
desc 'Report on the host inventory'
|
37
|
+
long_desc 'Report on the host inventory'
|
38
|
+
command :inventory do |c|
|
39
|
+
c.action do |global_options, options, args|
|
40
|
+
hosts = global_options[:hosts]
|
41
|
+
tags = global_options[:tags]
|
42
|
+
|
43
|
+
$app.set_verbose(global_options[:debug] || global_options[:verbose])
|
44
|
+
|
45
|
+
$app.report_inventory(hosts, tags: tags)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
desc 'Run an operation from a package'
|
50
|
+
long_desc 'Run the specified operation found within the specified package'
|
51
|
+
arg_name 'args', :multiple
|
52
|
+
command :run do |c|
|
53
|
+
c.flag [:u, :user], desc: "Specify the user that the operation will run as"
|
54
|
+
c.switch :pass, desc: "Prompt for a sudo password"
|
55
|
+
c.flag [:p, :params], desc: "JSON string that represents the input parameters for the operation. The JSON string must conform to the params schema for the operation."
|
56
|
+
|
57
|
+
c.action do |global_options, options, args|
|
58
|
+
hosts = global_options[:hosts] || []
|
59
|
+
tags = global_options[:tags] || []
|
60
|
+
|
61
|
+
$app.set_inventory_hosts(hosts)
|
62
|
+
$app.set_inventory_tags(tags)
|
63
|
+
|
64
|
+
verbose = case
|
65
|
+
when global_options[:debug]
|
66
|
+
2
|
67
|
+
when global_options[:verbose]
|
68
|
+
1
|
69
|
+
end
|
70
|
+
|
71
|
+
user = options[:user]
|
72
|
+
params = options[:params]
|
73
|
+
|
74
|
+
$app.set_verbose(verbose)
|
75
|
+
$app.set_params(params)
|
76
|
+
|
77
|
+
$app.set_sudo_user(user) if user
|
78
|
+
|
79
|
+
if options[:pass]
|
80
|
+
$app.prompt_sudo_password
|
81
|
+
end
|
82
|
+
|
83
|
+
# puts "verbose"
|
84
|
+
# puts verbose.inspect
|
85
|
+
# puts "user"
|
86
|
+
# puts user.inspect
|
87
|
+
# puts "args"
|
88
|
+
# puts args.inspect
|
89
|
+
|
90
|
+
exit_status = $app.run(args)
|
91
|
+
|
92
|
+
exit_now!("error", exit_status) unless exit_status == 0
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
desc 'Bundle dependencies'
|
97
|
+
long_desc 'Download and bundle the dependencies for the operations found in the current directory'
|
98
|
+
command :bundle do |c|
|
99
|
+
|
100
|
+
desc 'Update bundle dependencies'
|
101
|
+
long_desc 'Download and bundle the latest versions of dependencies for the current package'
|
102
|
+
c.command :update do |update|
|
103
|
+
update.action do |global_options, options, args|
|
104
|
+
$app.bundle_update
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
desc 'List bundle dependencies'
|
109
|
+
long_desc 'List bundle dependencies'
|
110
|
+
c.command :status do |status|
|
111
|
+
status.action do |global_options, options, args|
|
112
|
+
$app.bundle_status
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
desc 'Unzip bundle'
|
117
|
+
long_desc 'Unzip the specified bundle zip file to the specified directory'
|
118
|
+
c.command :unzip do |unzip|
|
119
|
+
unzip.flag [:o, :output], desc: "Specify the output directory"
|
120
|
+
|
121
|
+
unzip.action do |global_options, options, args|
|
122
|
+
output_dir = options[:output]
|
123
|
+
zip_file_path = args.first
|
124
|
+
|
125
|
+
destination_dir = $app.unzip(zip_file_path, output_dir)
|
126
|
+
|
127
|
+
if destination_dir
|
128
|
+
puts destination_dir
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
c.default_command :status
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def main
|
139
|
+
exit_status = OpsWalrus::Cli.run(ARGV)
|
140
|
+
exit exit_status
|
141
|
+
end
|
142
|
+
|
143
|
+
main if __FILE__ == $0
|
@@ -0,0 +1,177 @@
|
|
1
|
+
require "set"
|
2
|
+
require "sshkit"
|
3
|
+
|
4
|
+
require_relative "interaction_handlers"
|
5
|
+
|
6
|
+
module OpsWalrus
|
7
|
+
|
8
|
+
module HostDSL
|
9
|
+
# returns the stdout from the command
|
10
|
+
def sh(desc_or_cmd = nil, cmd = nil, stdin: nil, &block)
|
11
|
+
out, err, status = *shell!(desc_or_cmd, cmd, block, stdin: stdin)
|
12
|
+
out
|
13
|
+
end
|
14
|
+
|
15
|
+
# returns the tuple: [stdout, stderr, exit_status]
|
16
|
+
def shell(desc_or_cmd = nil, cmd = nil, stdin: nil, &block)
|
17
|
+
shell!(desc_or_cmd, cmd, block, stdin: stdin)
|
18
|
+
end
|
19
|
+
|
20
|
+
# returns the tuple: [stdout, stderr, exit_status]
|
21
|
+
def shell!(desc_or_cmd = nil, cmd = nil, block = nil, stdin: nil)
|
22
|
+
# description = nil
|
23
|
+
|
24
|
+
return ["", "", 0] if !desc_or_cmd && !cmd && !block # we were told to do nothing; like hitting enter at the bash prompt; we can do nothing successfully
|
25
|
+
|
26
|
+
description = desc_or_cmd if cmd || block
|
27
|
+
cmd = block.call if block
|
28
|
+
cmd ||= desc_or_cmd
|
29
|
+
|
30
|
+
if self.alias
|
31
|
+
print "[#{self.alias} | #{host}] "
|
32
|
+
else
|
33
|
+
print "[#{host}] "
|
34
|
+
end
|
35
|
+
print "#{description}: " if description
|
36
|
+
puts cmd
|
37
|
+
|
38
|
+
cmd = WalrusLang.render(cmd, block.binding) if block && cmd =~ /{{.*}}/
|
39
|
+
return unless cmd && !cmd.strip.empty?
|
40
|
+
|
41
|
+
#cmd = Shellwords.escape(cmd)
|
42
|
+
|
43
|
+
# puts "shell: #{cmd}"
|
44
|
+
# puts "shell: #{cmd.inspect}"
|
45
|
+
# puts "sudo_password: #{sudo_password}"
|
46
|
+
|
47
|
+
sshkit_cmd = execute_cmd(cmd)
|
48
|
+
|
49
|
+
[sshkit_cmd.full_stdout, sshkit_cmd.full_stderr, sshkit_cmd.exit_status]
|
50
|
+
end
|
51
|
+
|
52
|
+
# def init_brew
|
53
|
+
# execute('eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"')
|
54
|
+
# end
|
55
|
+
|
56
|
+
def run_ops(cmd)
|
57
|
+
shell!("/home/linuxbrew/.linuxbrew/bin/gem exec opswalrus ops #{cmd}")
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
|
62
|
+
class HostProxy
|
63
|
+
def initialize(host)
|
64
|
+
@host = host
|
65
|
+
end
|
66
|
+
def method_missing()
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
class Host
|
71
|
+
include HostDSL
|
72
|
+
|
73
|
+
def initialize(name_or_ip_or_cidr, tags = [], props = {})
|
74
|
+
@name_or_ip_or_cidr = name_or_ip_or_cidr
|
75
|
+
@tags = tags.to_set
|
76
|
+
@props = props.is_a?(Array) ? {"tags" => props} : props.to_h
|
77
|
+
end
|
78
|
+
|
79
|
+
def host
|
80
|
+
@name_or_ip_or_cidr
|
81
|
+
end
|
82
|
+
|
83
|
+
def ssh_port
|
84
|
+
@props["port"]
|
85
|
+
end
|
86
|
+
|
87
|
+
def ssh_user
|
88
|
+
@props["user"]
|
89
|
+
end
|
90
|
+
|
91
|
+
def ssh_password
|
92
|
+
@props["password"]
|
93
|
+
end
|
94
|
+
|
95
|
+
def ssh_keys
|
96
|
+
@props["keys"]
|
97
|
+
end
|
98
|
+
|
99
|
+
def hash
|
100
|
+
@name_or_ip_or_cidr.hash
|
101
|
+
end
|
102
|
+
|
103
|
+
def eql?(other)
|
104
|
+
self.class == other.class && self.hash == other.hash
|
105
|
+
end
|
106
|
+
|
107
|
+
def to_s
|
108
|
+
@name_or_ip_or_cidr
|
109
|
+
end
|
110
|
+
|
111
|
+
def alias
|
112
|
+
@props["alias"]
|
113
|
+
end
|
114
|
+
|
115
|
+
def tag!(*tags)
|
116
|
+
enumerables, scalars = tags.partition {|t| Enumerable === t }
|
117
|
+
@tags.merge(scalars)
|
118
|
+
enumerables.each {|enum| @tags.merge(enum) }
|
119
|
+
@tags
|
120
|
+
end
|
121
|
+
|
122
|
+
def tags
|
123
|
+
@tags
|
124
|
+
end
|
125
|
+
|
126
|
+
def summary(verbose = false)
|
127
|
+
report = "#{to_s}\n tags: #{tags.sort.join(', ')}"
|
128
|
+
if verbose
|
129
|
+
@props.reject{|k,v| k == 'tags' }.each {|k,v| report << "\n #{k}: #{v}" }
|
130
|
+
end
|
131
|
+
report
|
132
|
+
end
|
133
|
+
|
134
|
+
def sshkit_host
|
135
|
+
@sshkit_host ||= ::SSHKit::Host.new({
|
136
|
+
hostname: host,
|
137
|
+
port: ssh_port || 22,
|
138
|
+
user: ssh_user || raise("No ssh user specified to connect to #{host}"),
|
139
|
+
password: ssh_password,
|
140
|
+
keys: ssh_keys
|
141
|
+
})
|
142
|
+
end
|
143
|
+
|
144
|
+
def set_ssh_connection(sshkit_backend)
|
145
|
+
@sshkit_backend = sshkit_backend
|
146
|
+
end
|
147
|
+
|
148
|
+
def clear_ssh_connection
|
149
|
+
@sskit_backend = nil
|
150
|
+
end
|
151
|
+
|
152
|
+
def execute(*args)
|
153
|
+
# puts "interaction handler responds with: #{ssh_password}"
|
154
|
+
@sshkit_backend.capture(*args, interaction_handler: SudoPasswordMapper.new(ssh_password).interaction_handler, verbosity: :info)
|
155
|
+
# @sshkit_backend.capture(*args, interaction_handler: SudoPromptInteractionHandler.new, verbosity: :info)
|
156
|
+
end
|
157
|
+
|
158
|
+
def execute_cmd(*args)
|
159
|
+
@sshkit_backend.execute_cmd(*args, interaction_handler: SudoPasswordMapper.new(ssh_password).interaction_handler, verbosity: :info)
|
160
|
+
end
|
161
|
+
|
162
|
+
def upload(local_path_or_io, remote_path)
|
163
|
+
source = local_path_or_io.is_a?(IO) ? local_path_or_io : local_path_or_io.to_s
|
164
|
+
@sshkit_backend.upload!(source, remote_path.to_s)
|
165
|
+
end
|
166
|
+
|
167
|
+
def download(remote_path, local_path)
|
168
|
+
@sshkit_backend.download!(remote_path.to_s, local_path.to_s)
|
169
|
+
end
|
170
|
+
|
171
|
+
def run
|
172
|
+
|
173
|
+
end
|
174
|
+
|
175
|
+
end
|
176
|
+
|
177
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require "pathname"
|
2
|
+
require "yaml"
|
3
|
+
require_relative "host"
|
4
|
+
|
5
|
+
module OpsWalrus
|
6
|
+
class HostsFile
|
7
|
+
attr_accessor :hosts_file_path
|
8
|
+
attr_accessor :yaml
|
9
|
+
|
10
|
+
def initialize(hosts_file_path)
|
11
|
+
@hosts_file_path = File.absolute_path(hosts_file_path)
|
12
|
+
@yaml = YAML.load(File.read(hosts_file_path)) if File.exist?(hosts_file_path)
|
13
|
+
# puts @yaml.inspect
|
14
|
+
end
|
15
|
+
|
16
|
+
def defaults
|
17
|
+
@defaults ||= (@yaml["defaults"] || @yaml["default"] || {})
|
18
|
+
end
|
19
|
+
|
20
|
+
# returns an Array(Host)
|
21
|
+
def hosts
|
22
|
+
# @yaml is a map of the form:
|
23
|
+
# {
|
24
|
+
# "198.23.249.13"=>{"hostname"=>"web1", "tags"=>["monopod", "racknerd", "vps", "2.5gb", "web1", "web", "ubuntu22.04"]},
|
25
|
+
# "107.175.91.150"=>{"tags"=>["monopod", "racknerd", "vps", "2.5gb", "pbx1", "pbx", "ubuntu22.04"]},
|
26
|
+
# "198.23.249.16"=>{"tags"=>["racknerd", "vps", "4gb", "kvm", "ubuntu20.04", "minecraft"]},
|
27
|
+
# "198.211.15.34"=>{"tags"=>["racknerd", "vps", "1.5gb", "kvm", "ubuntu20.04", "blog"]},
|
28
|
+
# "homeassistant.locallan.network"=>{"tags"=>["local", "homeassistant", "home", "rpi"]},
|
29
|
+
# "synology.locallan.network"=>{"tags"=>["local", "synology", "nas"]},
|
30
|
+
# "pfsense.locallan.network"=>false,
|
31
|
+
# "192.168.56.10"=>{"tags"=>["web", "vagrant"]}
|
32
|
+
# }
|
33
|
+
@yaml.map do |host_ref, host_attrs|
|
34
|
+
next if host_ref == "default" || host_ref == "defaults" # this maps to a nil
|
35
|
+
|
36
|
+
host_params = host_attrs.is_a?(Hash) ? host_attrs : {}
|
37
|
+
|
38
|
+
Host.new(host_ref, tags(host_ref), defaults.merge(host_params))
|
39
|
+
end.compact
|
40
|
+
end
|
41
|
+
|
42
|
+
def tags(host)
|
43
|
+
host_attrs = @yaml[host]
|
44
|
+
|
45
|
+
case host_attrs
|
46
|
+
when Array
|
47
|
+
tags = host_attrs
|
48
|
+
tags.compact.uniq
|
49
|
+
when Hash
|
50
|
+
tags = host_attrs["tags"] || []
|
51
|
+
tags.compact.uniq
|
52
|
+
end || []
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'sshkit'
|
2
|
+
|
3
|
+
module OpsWalrus
|
4
|
+
|
5
|
+
class PasswdInteractionHandler
|
6
|
+
def on_data(command, stream_name, data, channel)
|
7
|
+
# puts data
|
8
|
+
case data
|
9
|
+
when '(current) UNIX password: '
|
10
|
+
channel.send_data("old_pw\n")
|
11
|
+
when 'Enter new UNIX password: ', 'Retype new UNIX password: '
|
12
|
+
channel.send_data("new_pw\n")
|
13
|
+
when 'passwd: password updated successfully'
|
14
|
+
else
|
15
|
+
raise "Unexpected stderr #{stderr}"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
class SudoPasswordMapper
|
21
|
+
def initialize(sudo_password)
|
22
|
+
@sudo_password = sudo_password
|
23
|
+
end
|
24
|
+
|
25
|
+
def interaction_handler
|
26
|
+
SSHKit::MappingInteractionHandler.new({
|
27
|
+
/\[sudo\] password for .*?:\s*/ => "#{@sudo_password}\n",
|
28
|
+
# /\s+/ => nil, # unnecessary
|
29
|
+
}, :info)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class SudoPromptInteractionHandler
|
34
|
+
def on_data(command, stream_name, data, channel)
|
35
|
+
# puts "0" * 80
|
36
|
+
# puts data.inspect
|
37
|
+
case data
|
38
|
+
when /\[sudo\] password for/
|
39
|
+
if channel.respond_to?(:send_data) # Net::SSH channel
|
40
|
+
channel.send_data("conquer\n")
|
41
|
+
elsif channel.respond_to?(:write) # IO
|
42
|
+
channel.write("conquer\n")
|
43
|
+
end
|
44
|
+
when /\s+/
|
45
|
+
puts 'space, do nothing'
|
46
|
+
else
|
47
|
+
raise "Unexpected prompt: #{data} on stream #{stream_name} and channel #{channel.inspect}"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
end
|