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.
@@ -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