opswalrus 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,258 @@
1
+ require 'json'
2
+ require 'shellwords'
3
+ require 'socket'
4
+ require 'sshkit'
5
+
6
+ require_relative 'traversable'
7
+ require_relative 'walrus_lang'
8
+
9
+ module OpsWalrus
10
+
11
+ class ImportReference
12
+ attr_accessor :local_name
13
+
14
+ def initialize(local_name)
15
+ @local_name = local_name
16
+ end
17
+ end
18
+
19
+ class PackageDependencyReference < ImportReference
20
+ attr_accessor :package_reference
21
+ def initialize(local_name, package_reference)
22
+ super(local_name)
23
+ @package_reference = package_reference
24
+ end
25
+ end
26
+
27
+ class DirectoryReference < ImportReference
28
+ attr_accessor :dirname
29
+ def initialize(local_name, dirname)
30
+ super(local_name)
31
+ @dirname = dirname
32
+ end
33
+ end
34
+
35
+ class OpsFileReference < ImportReference
36
+ attr_accessor :ops_file_path
37
+ def initialize(local_name, ops_file_path)
38
+ super(local_name)
39
+ @ops_file_path = ops_file_path
40
+ end
41
+ end
42
+
43
+ # Namespace is really just a Map of symbol_name -> (Namespace | OpsFile) pairs
44
+ class Namespace
45
+ attr_accessor :runtime_env
46
+ attr_accessor :dirname
47
+ attr_accessor :symbol_table
48
+
49
+ # dirname is an absolute path
50
+ def initialize(runtime_env, dirname)
51
+ @runtime_env = runtime_env
52
+ @dirname = dirname
53
+ @symbol_table = {} # "symbol_name" => ops_file_or_child_namespace
54
+ end
55
+
56
+ def add(symbol_name, ops_file_or_child_namespace)
57
+ @symbol_table[symbol_name.to_s] = ops_file_or_child_namespace
58
+ end
59
+
60
+ def resolve_symbol(symbol_name)
61
+ @symbol_table[symbol_name.to_s]
62
+ end
63
+
64
+ def method_missing(name, *args, **kwargs, &block)
65
+ resolved_symbol = resolve_symbol(name)
66
+ case resolved_symbol
67
+ when Namespace
68
+ resolved_symbol
69
+ when OpsFile
70
+ params_hash = resolved_symbol.build_params_hash(*args, **kwargs)
71
+ resolved_symbol.invoke(runtime_env, params_hash)
72
+ end
73
+ end
74
+ end
75
+
76
+ # the assumption is that we have a bundle directory with all the packages in it
77
+ # and the bundle directory is the root directory
78
+ class LoadPath
79
+ include Traversable
80
+
81
+ attr_accessor :dir
82
+ attr_accessor :runtime_env
83
+
84
+ def initialize(runtime_env, dir)
85
+ @runtime_env = runtime_env
86
+ @dir = dir
87
+ @root_namespace = build_symbol_resolution_tree(@dir)
88
+ @path_map = build_path_map(@root_namespace)
89
+ end
90
+
91
+ # returns a tree of Namespace -> {Namespace* -> {Namespace* -> ..., OpsFile*}, OpsFile*}
92
+ def build_symbol_resolution_tree(directory_path)
93
+ namespace = Namespace.new(runtime_env, directory_path)
94
+
95
+ directory_path.glob("*.ops").each do |ops_file_path|
96
+ ops_file = OpsFile.new(@app, ops_file_path)
97
+ namespace.add(ops_file.basename, ops_file)
98
+ end
99
+
100
+ directory_path.glob("*").
101
+ select(&:directory?).
102
+ reject {|dir| dir.basename.to_s.downcase == Bundler::BUNDLE_DIR }.
103
+ each do |dir_path|
104
+ dir_basename = dir_path.basename
105
+ unless namespace.resolve_symbol(dir_basename)
106
+ child_namespace = build_symbol_resolution_tree(dir_path)
107
+ namespace.add(dir_basename, child_namespace)
108
+ end
109
+ end
110
+
111
+ namespace
112
+ end
113
+
114
+ # returns a Map of path -> (Namespace | OpsFile) pairs
115
+ def build_path_map(root_namespace)
116
+ path_map = {}
117
+
118
+ pre_order_traverse(root_namespace) do |namespace_or_ops_file|
119
+ case namespace_or_ops_file
120
+ when Namespace
121
+ path_map[namespace_or_ops_file.dirname] = namespace_or_ops_file
122
+ when OpsFile
123
+ path_map[namespace_or_ops_file.ops_file_path] = namespace_or_ops_file
124
+ end
125
+
126
+ namespace_or_ops_file.symbol_table.values if namespace_or_ops_file.is_a?(Namespace)
127
+ end
128
+
129
+ path_map
130
+ end
131
+
132
+ # returns a Namespace
133
+ def lookup_namespace(ops_file)
134
+ @path_map[ops_file.dirname]
135
+ end
136
+
137
+ # returns a Namespace or OpsFile
138
+ def resolve_symbol(origin_ops_file, symbol_name)
139
+ lookup_namespace(origin_ops_file)&.resolve_symbol(symbol_name)
140
+ end
141
+
142
+ # returns a Namespace | OpsFile
143
+ def resolve_import_reference(origin_ops_file, import_reference)
144
+ case import_reference
145
+ when PackageDependencyReference
146
+ # puts "root namespace: #{@root_namespace.symbol_table}"
147
+ @root_namespace.resolve_symbol(import_reference.package_reference.local_name) # returns the Namespace associated with the bundled package dirname (i.e. the local name)
148
+ when DirectoryReference
149
+ @path_map[import_reference.dirname]
150
+ when OpsFileReference
151
+ @path_map[import_reference.ops_file_path]
152
+ end
153
+ end
154
+ end
155
+
156
+ class RuntimeEnvironment
157
+ include Traversable
158
+
159
+ attr_accessor :app
160
+ attr_accessor :pty
161
+
162
+ def initialize(app)
163
+ @app = app
164
+ @bundle_load_path = LoadPath.new(self, @app.bundle_dir)
165
+ @app_load_path = LoadPath.new(self, @app.pwd)
166
+
167
+ configure_sshkit
168
+ end
169
+
170
+ # configure sshkit globally
171
+ def configure_sshkit
172
+ SSHKit.config.use_format :blackhole
173
+ SSHKit.config.output_verbosity = :info
174
+
175
+ if app.debug?
176
+ SSHKit.config.use_format :pretty
177
+ # SSHKit.config.use_format :simpletext
178
+ SSHKit.config.output_verbosity = :debug
179
+ elsif app.verbose?
180
+ # SSHKit.config.use_format :dot
181
+ SSHKit.config.output_verbosity = :info
182
+ end
183
+
184
+ SSHKit::Backend::Netssh.configure do |ssh|
185
+ ssh.pty = true # necessary for interaction with sudo on the remote host
186
+ ssh.connection_timeout = 60 # seconds
187
+ ssh.ssh_options = { # this hash is passed in as the options hash (3rd argument) to Net::SSH.start(host, user, options) - see https://net-ssh.github.io/net-ssh/Net/SSH.html
188
+ auth_methods: %w(publickey password), # :auth_methods => an array of authentication methods to try
189
+ # :forward_agent => set to true if you want the SSH agent connection to be forwarded
190
+ # :keepalive => set to true to send a keepalive packet to the SSH server when there's no traffic between the SSH server and Net::SSH client for the keepalive_interval seconds. Defaults to false.
191
+ # :keepalive_interval => the interval seconds for keepalive. Defaults to 300 seconds.
192
+ # :keepalive_maxcount => the maximun number of keepalive packet miss allowed. Defaults to 3
193
+ timeout: 2, # :timeout => how long to wait for the initial connection to be made
194
+ }
195
+ end
196
+ SSHKit::Backend::Netssh.pool.idle_timeout = 1 # seconds
197
+ end
198
+
199
+ def debug?
200
+ @app.debug?
201
+ end
202
+
203
+ def verbose?
204
+ @app.verbose?
205
+ end
206
+
207
+ def local_hostname
208
+ @app.local_hostname
209
+ end
210
+
211
+ def sudo_user
212
+ @app.sudo_user
213
+ end
214
+
215
+ def sudo_password
216
+ @app.sudo_password
217
+ end
218
+
219
+ def zip_bundle_path
220
+ app.zip
221
+ end
222
+
223
+ def run(entry_point_ops_file, params_hash)
224
+ runtime_env = self
225
+ SSHKit::Backend::LocalPty.new do
226
+ runtime_env.pty = self
227
+ retval = runtime_env.invoke(entry_point_ops_file, params_hash)
228
+ runtime_env.pty = nil
229
+ retval
230
+ end.run
231
+ end
232
+
233
+ def invoke(ops_file, params_hash)
234
+ ops_file.invoke(self, params_hash)
235
+ end
236
+
237
+ # returns a Namespace or OpsFile
238
+ def resolve_sibling_symbol(origin_ops_file, symbol_name)
239
+ @app_load_path.resolve_symbol(origin_ops_file, symbol_name)
240
+ end
241
+
242
+ # returns a Namespace | OpsFile
243
+ def resolve_import_reference(origin_ops_file, import_reference)
244
+ case import_reference
245
+
246
+ # we know we're dealing with a package dependency reference, so we want to do the lookup in the bundle load path, where package dependencies live
247
+ when PackageDependencyReference
248
+ @bundle_load_path.resolve_import_reference(origin_ops_file, import_reference)
249
+
250
+ # we know we're dealing with a directory reference or OpsFile reference outside of the bundle dir, so we want to do the lookup in the app load path
251
+ when DirectoryReference, OpsFileReference
252
+ @app_load_path.resolve_import_reference(origin_ops_file, import_reference)
253
+ end
254
+ end
255
+
256
+ end
257
+
258
+ end
@@ -0,0 +1,51 @@
1
+ require 'digest'
2
+ require 'logger'
3
+ require 'securerandom'
4
+ require 'sshkit'
5
+
6
+ require_relative 'local_non_blocking_backend'
7
+ require_relative 'local_pty_backend'
8
+
9
+ module SSHKit
10
+ module Backend
11
+ class Abstract
12
+ def execute_cmd(*args)
13
+ # puts "args: #{args.inspect}"
14
+ options = { verbosity: :debug, strip: true }.merge(args.extract_options!)
15
+ # puts "options: #{options.inspect}"
16
+ create_command_and_execute(args, options)
17
+ end
18
+ end
19
+ end
20
+
21
+ module Runner
22
+ class Sequential < Abstract
23
+ def run_backend(host, &block)
24
+ backend(host, &block).run
25
+ # rescue ::StandardError => e
26
+ # e2 = ExecuteError.new e
27
+ # raise e2, "Exception while executing #{host.user ? "as #{host.user}@" : "on host "}#{host}: #{e.message}"
28
+ end
29
+ end
30
+ end
31
+
32
+ class Command
33
+ # Initialize a new Command object
34
+ #
35
+ # @param [Array] A list of arguments, the first is considered to be the
36
+ # command name, with optional variadaric args
37
+ # @return [Command] An un-started command object with no exit staus, and
38
+ # nothing in stdin or stdout
39
+ #
40
+ def initialize(*args)
41
+ raise ArgumentError, "Must pass arguments to Command.new" if args.empty?
42
+ @options = default_options.merge(args.extract_options!)
43
+ # @command = sanitize_command(args.shift)
44
+ @command = args.shift
45
+ @args = args
46
+ @options.symbolize_keys! # @options.transform_keys!(&:to_sym)
47
+ @stdout, @stderr, @full_stdout, @full_stderr = String.new, String.new, String.new, String.new
48
+ @uuid = Digest::SHA1.hexdigest(SecureRandom.random_bytes(10))[0..7]
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,15 @@
1
+ module OpsWalrus
2
+ module Traversable
3
+ # the yield block visits the node and returns children that should be visited
4
+ def pre_order_traverse(root, observed_nodes = Set.new, &visit_fn_block)
5
+ # there shouldn't be any cycles in a tree, but we're going to make sure!
6
+ return if observed_nodes.include?(root)
7
+ observed_nodes << root
8
+
9
+ children = visit_fn_block.call(root)
10
+ children&.each do |child_node|
11
+ pre_order_traverse(child_node, observed_nodes, &visit_fn_block)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ module OpsWalrus
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,83 @@
1
+ require "citrus"
2
+ require "stringio"
3
+
4
+ module WalrusLang
5
+ module Templates
6
+ def render(binding)
7
+ captures(:template).map{|t| t.render(binding) }.join
8
+ end
9
+ end
10
+
11
+ module Template
12
+ def render(binding)
13
+ s = StringIO.new
14
+ if mustache = capture(:mustache)
15
+ s << capture(:pre).value
16
+ s << mustache.render(binding)
17
+ s << capture(:post).value
18
+ else
19
+ s << capture(:fallthrough).value
20
+ end
21
+ s.string
22
+ end
23
+ end
24
+
25
+ module Mustache
26
+ def render(binding)
27
+ eval(capture(:expr).render(binding), binding)
28
+ end
29
+ end
30
+
31
+ Grammar = <<-GRAMMAR
32
+ grammar WalrusLang::Parser
33
+ rule templates
34
+ (template*) <WalrusLang::Templates>
35
+ end
36
+
37
+ rule template
38
+ ((pre:(non_mustache?) mustache post:(non_mustache?)) | fallthrough:non_mustache) <WalrusLang::Template>
39
+ end
40
+
41
+ rule before_mustache
42
+ ~'{{'
43
+ end
44
+
45
+ rule non_mustache
46
+ ~('{{' | '}}')
47
+ end
48
+
49
+ rule mustache
50
+ ('{{' expr:templates '}}') <WalrusLang::Mustache>
51
+ end
52
+ end
53
+ GRAMMAR
54
+
55
+ Citrus.eval(Grammar)
56
+
57
+ def self.render(template, binding)
58
+ ast = WalrusLang::Parser.parse(template)
59
+ ast.render(binding)
60
+ end
61
+ end
62
+
63
+ class Binding
64
+ def local_vars_hash
65
+ local_variables.map {|s| [s.to_s, local_variable_get(s)] }.to_h
66
+ end
67
+ end
68
+
69
+ def mustache(&block)
70
+ template_string = block.call
71
+ template_string =~ /{{.*}}/ ? WalrusLang.render(block.call, block.binding) : template_string
72
+ end
73
+
74
+
75
+ # foo = 1
76
+ # bar = 2
77
+ # # m = TemplateLang.parse("abc; {{ 'foo' * bar }} def ")
78
+ # m = WalrusLang::Parser.parse("abc; {{ 'foo{{1+2}}' * bar }} def {{ 4 * 4 }}; def")
79
+ # # m = TemplateLang.parse("a{{b{{c}}d}}e{{f}}g{{h{{i{{j{{k{{l}}m{{n}}o}}p}}}}}}")
80
+ # # puts m.dump
81
+ # puts m.render(binding)
82
+
83
+ # puts(mustache { "abc {{ 1 + 2 }} def" })
@@ -0,0 +1,57 @@
1
+ require 'zip'
2
+
3
+ module OpsWalrus
4
+ class DirZipper
5
+
6
+ # Zip the input directory.
7
+ # returns output_file
8
+ def self.zip(input_dir, output_file)
9
+ entries = Dir.entries(input_dir) - %w[. ..]
10
+
11
+ ::Zip::File.open(output_file, create: true) do |zipfile|
12
+ write_entries(input_dir, entries, '', zipfile)
13
+ end
14
+
15
+ output_file
16
+ end
17
+
18
+ # returns output_dir
19
+ def self.unzip(input_file, output_dir)
20
+ if !File.exist?(output_dir) || File.directory?(output_dir)
21
+ FileUtils.mkdir_p(output_dir)
22
+ ::Zip::File.foreach(input_file) do |entry|
23
+ path = File.join(output_dir, entry.name)
24
+ entry.extract(path) unless File.exist?(path)
25
+ end
26
+ else
27
+ raise Error, "#{output_dir} is not a directory"
28
+ end
29
+
30
+ output_dir
31
+ end
32
+
33
+ def self.write_entries(input_dir, entries, path, zipfile)
34
+ entries.each do |e|
35
+ zipfile_path = path == '' ? e : File.join(path, e)
36
+ disk_file_path = File.join(input_dir, zipfile_path)
37
+
38
+ if File.directory?(disk_file_path)
39
+ zipfile.mkdir(zipfile_path)
40
+ subdir = Dir.entries(disk_file_path) - %w[. ..]
41
+ write_entries(input_dir, subdir, zipfile_path, zipfile)
42
+ else
43
+ zipfile.add(zipfile_path, disk_file_path)
44
+ end
45
+ end
46
+ end
47
+
48
+ end
49
+ end
50
+
51
+ # this is just to test the zip function
52
+ def main
53
+ OpsWalrus::DirZipper.zip("../example/davidinfra", "test.zip")
54
+ OpsWalrus::DirZipper.unzip("test.zip", "test")
55
+ end
56
+
57
+ main if __FILE__ == $0
data/lib/opswalrus.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ require 'rubygems'
3
+ require 'bundler/setup'
4
+
5
+ require_relative 'opswalrus/app'
6
+ require_relative 'opswalrus/cli'
7
+ require_relative 'opswalrus/version'
8
+
9
+ module OpsWalrusWalrus
10
+ end
data/opswalrus.gemspec ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/opswalrus/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "opswalrus"
7
+ spec.version = OpsWalrus::VERSION
8
+ spec.authors = ["David Ellis"]
9
+ spec.email = ["david@conquerthelawn.com"]
10
+
11
+ spec.summary = "opswalrus is a tool that runs scripts against a fleet of hosts"
12
+ spec.description = "opswalrus is a tool that runs scripts against a fleet of hosts hosts. It's kind of like Ansible, but aims to be simpler to use."
13
+ spec.homepage = "https://github.com/opswalrus/opswalrus"
14
+ spec.required_ruby_version = ">= 2.6.0"
15
+
16
+ # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = "https://github.com/opswalrus/opswalrus"
20
+ # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor])
27
+ end
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ # gem dependencies
34
+ spec.add_dependency "citrus"
35
+ spec.add_dependency "gli"
36
+ spec.add_dependency "git"
37
+ spec.add_dependency "rubyzip"
38
+
39
+ spec.add_dependency "bcrypt_pbkdf"
40
+ spec.add_dependency "ed25519"
41
+ spec.add_dependency "sshkit"
42
+
43
+ # For more information and examples about making a new gem, check out our
44
+ # guide at: https://bundler.io/guides/creating_gem.html
45
+ end
data/sig/opswalrus.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module OpsWalrusWalrus
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end