opswalrus 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
data/README.md
ADDED
@@ -0,0 +1,256 @@
|
|
1
|
+
# opswalrus
|
2
|
+
|
3
|
+
opswalrus is a tool that runs scripts against hosts. It's kind of like Ansible, but aims to be simpler to use.
|
4
|
+
|
5
|
+
# Examples
|
6
|
+
|
7
|
+
```bash
|
8
|
+
> ops run core/host info
|
9
|
+
{
|
10
|
+
success: true,
|
11
|
+
host: {
|
12
|
+
name: "davidlinux",
|
13
|
+
os: "Ubuntu 23.04 (lunar)",
|
14
|
+
kernel: "Linux 6.2.0-1007-lowlatency x86_64 GNU/Linux",
|
15
|
+
}
|
16
|
+
}
|
17
|
+
```
|
18
|
+
|
19
|
+
# Packages and Imports
|
20
|
+
|
21
|
+
## Ops Packages
|
22
|
+
|
23
|
+
An ops package is a directory containing a package.yml (or package.yaml) file.
|
24
|
+
|
25
|
+
- The package.yml (or package.yaml) file is called the package file.
|
26
|
+
- The directory containing the package file is called the package directory.
|
27
|
+
|
28
|
+
A package file looks like this:
|
29
|
+
```yaml
|
30
|
+
author: David Ellis
|
31
|
+
license: MIT
|
32
|
+
version: 1.0.0
|
33
|
+
dependencies:
|
34
|
+
core: davidkellis/ops_core
|
35
|
+
apt: davidkellis/ops_apt
|
36
|
+
```
|
37
|
+
|
38
|
+
## Ops Files
|
39
|
+
|
40
|
+
An ops package may also consist of ops files, arranged in an arbitrary directory structure.
|
41
|
+
|
42
|
+
- An ops file is a script to do do something, very much like a shell script.
|
43
|
+
- An ops file should try to implement an idempotent operation, such that repeated execution of the script results in the same desired state.
|
44
|
+
|
45
|
+
An ops file looks like this:
|
46
|
+
```
|
47
|
+
params:
|
48
|
+
ops_username: string
|
49
|
+
ops_ssh_public_key: string
|
50
|
+
hostname: string
|
51
|
+
|
52
|
+
output:
|
53
|
+
success: boolean,
|
54
|
+
error: string?
|
55
|
+
|
56
|
+
imports:
|
57
|
+
core: core # core references the bundled core package referenced in the package.yaml
|
58
|
+
svc: service # service references the bundled service package referenced in the package.yaml
|
59
|
+
...
|
60
|
+
|
61
|
+
desc "create the admin group if it doens't exist"
|
62
|
+
core.create_group name: "admin"
|
63
|
+
|
64
|
+
desc "set up passwordless sudo for admin group users"
|
65
|
+
core.replace_line file: "/etc/sudoers",
|
66
|
+
pattern: "^%admin",
|
67
|
+
line: "%admin ALL=(ALL) NOPASSWD: ALL",
|
68
|
+
verify: "/usr/sbin/visudo -cf %s"
|
69
|
+
|
70
|
+
desc "create the ops user and make it an admin user"
|
71
|
+
core.create_user name: params.ops_username
|
72
|
+
|
73
|
+
desc "set up authorized key for id_ansible ssh key (root user)"
|
74
|
+
core.ssh.add_authorized_key user: "root", key: ops_ssh_public_key
|
75
|
+
|
76
|
+
desc "set up authorized key for id_ansible ssh key (ops user)"
|
77
|
+
core.ssh.add_authorized_key user: ops_username, key: ops_ssh_public_key
|
78
|
+
|
79
|
+
desc "disable password authentication for root"
|
80
|
+
core.replace_line file: '/etc/ssh/sshd_config',
|
81
|
+
pattern: '^#?PermitRootLogin',
|
82
|
+
line: 'PermitRootLogin prohibit-password'
|
83
|
+
|
84
|
+
desc "restart sshd"
|
85
|
+
svc.restart name: "sshd"
|
86
|
+
|
87
|
+
{
|
88
|
+
success: true,
|
89
|
+
}
|
90
|
+
```
|
91
|
+
|
92
|
+
An ops file is broken up into two parts. The first part is an optional YAML block that describes the structure of the expected
|
93
|
+
input parameters, the structure of the expected JSON output message, and the package dependencies that the script
|
94
|
+
needs in order to run.
|
95
|
+
|
96
|
+
The YAML block is concluded with an elipsis, `...`, on a line by itself.
|
97
|
+
|
98
|
+
The YAML block and its associated trailing elipsis may be omitted.
|
99
|
+
|
100
|
+
Following the elipsis that concludes the YAML block is a block of Ruby code. The block of Ruby is executed with a number
|
101
|
+
of methods, constants, and libraries that are available as a kind of domain specific language (DSL). This DSL makes
|
102
|
+
writing ops scripts feel very much like writing standard bash shell scripts.
|
103
|
+
|
104
|
+
Ops file imports are a mapping consisting of a local name and a corresponding package reference.
|
105
|
+
|
106
|
+
## Package Bundles
|
107
|
+
|
108
|
+
When an ops file is run, the ops runtime will first bundle up the invoked ops file as well as all package dependencies
|
109
|
+
and place the bundle of associated ops packages and ops files into an ops bundle directory.
|
110
|
+
|
111
|
+
The bundle directory is named ops_bundle, and contains everything needed to run the specified ops file on either the
|
112
|
+
local host or a remote host.
|
113
|
+
|
114
|
+
The ops command will place the bundle directory in the directory from which the ops command is being run. So, if `pwd`
|
115
|
+
returns `/home/david/foo` and the ops command is run from within that directory, then the bundle directory will be placed
|
116
|
+
at `/home/david/foo/ops_bundle`.
|
117
|
+
|
118
|
+
The one exception to the normal bundle directory placement rule described in the previous paragraph is when the ops
|
119
|
+
command is being run from within a directory that is contained within a package directory. In that case, the bundle directory
|
120
|
+
will be placed inside the package directory. So, for example, if the directory structure looks like:
|
121
|
+
```
|
122
|
+
❯ tree pkg
|
123
|
+
pkg
|
124
|
+
├── apt
|
125
|
+
│ ├── install.ops
|
126
|
+
│ └── update.ops
|
127
|
+
├── core
|
128
|
+
│ ├── echo.ops
|
129
|
+
│ ├── host
|
130
|
+
│ │ ├── info.ops
|
131
|
+
│ │ └── info.rb
|
132
|
+
│ ├── package.yaml
|
133
|
+
│ ├── ssh_copy_id.ops
|
134
|
+
│ ├── touch.ops
|
135
|
+
│ └── whoami.ops
|
136
|
+
├── hardening
|
137
|
+
│ └── package.yaml
|
138
|
+
├── motd
|
139
|
+
│ ├── motd.ops
|
140
|
+
│ └── package.yaml
|
141
|
+
└── service
|
142
|
+
└── restart.ops
|
143
|
+
```
|
144
|
+
and the `pwd` command returns `pkg/core/host`, and the ops command is run from within `pkg/core/host`, then the bundle
|
145
|
+
directory will be placed at `pkg/core/ops_bundle`.
|
146
|
+
|
147
|
+
### Bundle Directory Contents
|
148
|
+
|
149
|
+
A bundle directory contains all the dependencies for a given ops file invocation. There are two possible cases:
|
150
|
+
1. The invoked ops file is part of a package
|
151
|
+
2. The invoked ops file is not part of a package
|
152
|
+
|
153
|
+
In case (1), when the ops file being invoked is part of a package, we'll call it P, then the bundle directory will contain
|
154
|
+
a copy of the package directory associated with P, as well as all of the package directories associated with all
|
155
|
+
transitive package dependencies of P. For example, if the ops file foo.ops is contained within the package directory
|
156
|
+
for the Bar package, and if the Bar package depends on the core package and the service package, then the
|
157
|
+
directory structure of the bundle directory would be:
|
158
|
+
```
|
159
|
+
❯ tree ops_bundle
|
160
|
+
ops_bundle
|
161
|
+
├── Bar
|
162
|
+
│ └── foo.ops
|
163
|
+
├── core
|
164
|
+
│ ├── echo.ops
|
165
|
+
│ ├── host
|
166
|
+
│ │ ├── info.ops
|
167
|
+
│ │ └── info.rb
|
168
|
+
│ ├── package.yaml
|
169
|
+
│ ├── ssh_copy_id.ops
|
170
|
+
│ ├── touch.ops
|
171
|
+
│ └── whoami.ops
|
172
|
+
└── service
|
173
|
+
└── restart.ops
|
174
|
+
```
|
175
|
+
|
176
|
+
In case (2), when the ops file being invoked is not part of a package, then the bundle directory will contain a copy
|
177
|
+
of the package directories associated with all transitive package dependencies of the ops file being invoked.
|
178
|
+
Additionally, the deepest nested directory containing all of the transitive ops file dependencies of the ops file being
|
179
|
+
invoked will be copied to the bundle directory.
|
180
|
+
|
181
|
+
## Import and Symbol Resolution
|
182
|
+
|
183
|
+
When the ops command bundles and runs an ops file, the rules that the runtime uses to resolve references to other ops
|
184
|
+
files is as follows; assume the following sample project directory structure:
|
185
|
+
|
186
|
+
Project directory structure:
|
187
|
+
```
|
188
|
+
davidinfra
|
189
|
+
├── caddy
|
190
|
+
│ ├── install
|
191
|
+
│ │ └── debian.ops
|
192
|
+
│ └── install.ops
|
193
|
+
├── hosts.yml
|
194
|
+
├── main.ops
|
195
|
+
├── prepare_host
|
196
|
+
│ ├── all.ops
|
197
|
+
│ ├── hostname.ops
|
198
|
+
│ └── ssh.ops
|
199
|
+
└── roles
|
200
|
+
└── web.ops
|
201
|
+
```
|
202
|
+
|
203
|
+
Corresponding bundle directory structure:
|
204
|
+
```
|
205
|
+
davidinfra
|
206
|
+
├── ops_bundle
|
207
|
+
│ ├── core
|
208
|
+
│ │ └──...
|
209
|
+
│ └── davidinfra
|
210
|
+
│ ├── caddy
|
211
|
+
│ │ ├── install
|
212
|
+
│ │ │ └── debian.ops
|
213
|
+
│ │ └── install.ops
|
214
|
+
│ ├── hosts.yml
|
215
|
+
│ ├── main.ops
|
216
|
+
│ ├── prepare_host
|
217
|
+
│ │ ├── all.ops
|
218
|
+
│ │ ├── hostname.ops
|
219
|
+
│ │ └── ssh.ops
|
220
|
+
│ └── roles
|
221
|
+
│ └── web.ops
|
222
|
+
├── caddy
|
223
|
+
│ ├── install
|
224
|
+
│ │ └── debian.ops
|
225
|
+
│ └── install.ops
|
226
|
+
├── hosts.yml
|
227
|
+
├── main.ops
|
228
|
+
├── prepare_host
|
229
|
+
│ ├── all.ops
|
230
|
+
│ ├── hostname.ops
|
231
|
+
│ └── ssh.ops
|
232
|
+
└── roles
|
233
|
+
└── web.ops
|
234
|
+
```
|
235
|
+
|
236
|
+
The import and symbol resolution rules are as follows:
|
237
|
+
|
238
|
+
1. An ops file implicitly imports all sibling ops files and directories that reside within its same parent directory.
|
239
|
+
Within the lexical scope of an ops file's ruby script, any ops files or subdirectories that are implicitly imported
|
240
|
+
may be referenced by their name.
|
241
|
+
For example:
|
242
|
+
- main.ops may invoke caddy/install.ops with the expression `caddy.install(...)`
|
243
|
+
- all.ops may invoke hostname.ops with the expression `hostname(...)`
|
244
|
+
2. If there is an ops file and a directory that share the same name (with the exception of the .ops file extension),
|
245
|
+
then only the install.ops file may be referenced and invoked by other ops files, while the directory of the same name,
|
246
|
+
and its contents, may only be referenced from within the ops file of the matching name. This allows the details of
|
247
|
+
an operation to be encapsulated away and a public API be exposed through the ops file, while the details of the
|
248
|
+
implementation are hidden away in the ops files within the directory.
|
249
|
+
For example:
|
250
|
+
- main.ops may invoke `caddy.install(...)`, but it may not invoke `caddy.install.debian(...)`
|
251
|
+
- install.ops may invoke `install.debian(...)`, and reference other files or subpackages within the caddy/install directory
|
252
|
+
3. Ops files may import packages or relative paths:
|
253
|
+
1. a package reference that matches one of the local package names in the dependencies captured in packages.yaml
|
254
|
+
2. a package reference that resolves to a relative path pointing at a package directory
|
255
|
+
3. a relative path that resolves to a directory containing ops files
|
256
|
+
4. a relative path that resolves to an ops file
|
data/Rakefile
ADDED
data/exe/ops
ADDED
data/exe/run_ops_bundle
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'opswalrus'
|
4
|
+
|
5
|
+
def main
|
6
|
+
zip_file_path = ARGV.shift.to_pathname
|
7
|
+
|
8
|
+
exit_status = 0
|
9
|
+
|
10
|
+
Dir.mktmpdir do |dir|
|
11
|
+
dir = dir.to_pathname
|
12
|
+
|
13
|
+
# unzip the bundle into the temp directory
|
14
|
+
OpsWalrus::DirZipper.unzip(zip_file_path, dir)
|
15
|
+
|
16
|
+
exit_status = OpsWalrus::Cli.run(ARGV)
|
17
|
+
end
|
18
|
+
|
19
|
+
exit exit_status
|
20
|
+
end
|
21
|
+
|
22
|
+
main
|
@@ -0,0 +1,328 @@
|
|
1
|
+
require "citrus"
|
2
|
+
require "git"
|
3
|
+
require "io/console"
|
4
|
+
require "json"
|
5
|
+
require "random/formatter"
|
6
|
+
require "shellwords"
|
7
|
+
require "socket"
|
8
|
+
require "stringio"
|
9
|
+
require "yaml"
|
10
|
+
require "pathname"
|
11
|
+
require_relative "host"
|
12
|
+
require_relative "hosts_file"
|
13
|
+
require_relative "operation_runner"
|
14
|
+
require_relative "bundler"
|
15
|
+
require_relative "package_file"
|
16
|
+
|
17
|
+
class String
|
18
|
+
def escape_single_quotes
|
19
|
+
gsub("'"){"\\'"}
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_pathname
|
23
|
+
Pathname.new(self)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class Pathname
|
28
|
+
def to_pathname
|
29
|
+
self
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
module OpsWalrus
|
34
|
+
class Error < StandardError
|
35
|
+
end
|
36
|
+
|
37
|
+
class App
|
38
|
+
def self.instance(*args)
|
39
|
+
@instance ||= new(*args)
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
attr_reader :local_hostname
|
44
|
+
|
45
|
+
def initialize(pwd = Dir.pwd)
|
46
|
+
@verbose = false
|
47
|
+
@sudo_user = nil
|
48
|
+
@sudo_password = nil
|
49
|
+
@inventory_host_references = []
|
50
|
+
@inventory_tag_selections = []
|
51
|
+
@params = nil
|
52
|
+
@pwd = pwd.to_pathname
|
53
|
+
@bundler = Bundler.new(@pwd)
|
54
|
+
@local_hostname = "localhost"
|
55
|
+
end
|
56
|
+
|
57
|
+
def to_s
|
58
|
+
"" # return empty string because we won't want anyone accidentally printing or inspecting @sudo_password
|
59
|
+
end
|
60
|
+
|
61
|
+
def inspect
|
62
|
+
"" # return empty string because we won't want anyone accidentally printing or inspecting @sudo_password
|
63
|
+
end
|
64
|
+
|
65
|
+
def set_local_hostname(hostname)
|
66
|
+
hostname = hostname.strip
|
67
|
+
@local_hostname = hostname.empty? ? "localhost" : hostname
|
68
|
+
end
|
69
|
+
|
70
|
+
def set_inventory_hosts(*hosts)
|
71
|
+
hosts.flatten!.compact!
|
72
|
+
@inventory_host_references.concat(hosts).compact!
|
73
|
+
end
|
74
|
+
|
75
|
+
def set_inventory_tags(*tags)
|
76
|
+
tags.flatten!.compact!
|
77
|
+
@inventory_tag_selections.concat(tags).compact!
|
78
|
+
end
|
79
|
+
|
80
|
+
def bundler
|
81
|
+
@bundler
|
82
|
+
end
|
83
|
+
|
84
|
+
def bundle_dir
|
85
|
+
@bundler.bundle_dir
|
86
|
+
end
|
87
|
+
|
88
|
+
def set_verbose(verbose)
|
89
|
+
@verbose = verbose
|
90
|
+
end
|
91
|
+
|
92
|
+
def verbose?
|
93
|
+
@verbose
|
94
|
+
end
|
95
|
+
|
96
|
+
def debug?
|
97
|
+
@verbose == 2
|
98
|
+
end
|
99
|
+
|
100
|
+
def set_pwd(pwd)
|
101
|
+
@pwd = pwd.to_pathname
|
102
|
+
@bundler = Bundler.new(@pwd)
|
103
|
+
end
|
104
|
+
|
105
|
+
def pwd
|
106
|
+
@pwd || raise("No working directory specified")
|
107
|
+
end
|
108
|
+
|
109
|
+
def set_sudo_user(user)
|
110
|
+
@sudo_user = user
|
111
|
+
end
|
112
|
+
|
113
|
+
def sudo_user
|
114
|
+
@sudo_user || "root"
|
115
|
+
end
|
116
|
+
|
117
|
+
def set_sudo_password(password)
|
118
|
+
@sudo_password = password
|
119
|
+
end
|
120
|
+
|
121
|
+
def prompt_sudo_password
|
122
|
+
password = IO::console.getpass("[ops] Enter sudo password to run sudo in local environment: ")
|
123
|
+
set_sudo_password(password)
|
124
|
+
nil
|
125
|
+
end
|
126
|
+
|
127
|
+
def sudo_password
|
128
|
+
@sudo_password
|
129
|
+
end
|
130
|
+
|
131
|
+
# params must be a string representation of a JSON object: '{}' | '{"key1": ... , ...}'
|
132
|
+
def set_params(params)
|
133
|
+
json_hash = JSON.parse(params) rescue nil
|
134
|
+
json_hash = json_hash.is_a?(Hash) ? json_hash : nil
|
135
|
+
|
136
|
+
@params = json_hash # @params returns a Hash or nil
|
137
|
+
end
|
138
|
+
|
139
|
+
# args is of the form ["github.com/davidkellis/my-package/sub-package1", "operation1", "arg1:val1", "arg2:val2", "arg3:val3"]
|
140
|
+
# if the first argument is the path to a .ops file, then treat it as a local path, and add the containing package
|
141
|
+
# to the load path
|
142
|
+
# otherwise, copy the
|
143
|
+
# returns the exit status code that the script should terminate with
|
144
|
+
def run(package_operation_and_args)
|
145
|
+
return 0 if package_operation_and_args.empty?
|
146
|
+
|
147
|
+
ops_file_path, operation_kv_args, tmp_dir = get_entry_point_ops_file_and_args(package_operation_and_args)
|
148
|
+
ops_file = OpsFile.new(self, ops_file_path)
|
149
|
+
|
150
|
+
# if the ops file is part of a package, then set the package directory as the app's pwd
|
151
|
+
# puts "run1: #{ops_file.ops_file_path}"
|
152
|
+
if ops_file.package_file && ops_file.package_file.dirname.to_s !~ /#{Bundler::BUNDLE_DIR}/
|
153
|
+
# puts "set pwd: #{ops_file.package_file.dirname}"
|
154
|
+
set_pwd(ops_file.package_file.dirname)
|
155
|
+
rebased_ops_file_relative_path = ops_file.ops_file_path.relative_path_from(ops_file.package_file.dirname)
|
156
|
+
# note: rebased_ops_file_relative_path is a relative path that is relative to ops_file.package_file.dirname
|
157
|
+
# puts "rebased path: #{rebased_ops_file_relative_path}"
|
158
|
+
absolute_ops_file_path = ops_file.package_file.dirname.join(rebased_ops_file_relative_path)
|
159
|
+
# puts "absolute path: #{absolute_ops_file_path}"
|
160
|
+
ops_file = OpsFile.new(self, absolute_ops_file_path)
|
161
|
+
end
|
162
|
+
# puts "run2: #{ops_file.ops_file_path}"
|
163
|
+
|
164
|
+
op = OperationRunner.new(self, ops_file)
|
165
|
+
# if op.requires_sudo?
|
166
|
+
# prompt_sudo_password unless sudo_password
|
167
|
+
# end
|
168
|
+
# exit_status, out, err, script_output_structure = op.run(operation_kv_args, params_json_hash: @params, verbose: @verbose)
|
169
|
+
result = op.run(operation_kv_args, params_json_hash: @params, verbose: @verbose)
|
170
|
+
exit_status = result.exit_status
|
171
|
+
|
172
|
+
if @verbose
|
173
|
+
puts "Op exit_status"
|
174
|
+
puts exit_status
|
175
|
+
|
176
|
+
# puts "Op stdout"
|
177
|
+
# puts out
|
178
|
+
|
179
|
+
# puts "Op stderr"
|
180
|
+
# puts err
|
181
|
+
|
182
|
+
puts "Op output"
|
183
|
+
# puts script_output_structure ? JSON.pretty_generate(script_output_structure) : nil.inspect
|
184
|
+
puts JSON.pretty_generate(result.value)
|
185
|
+
end
|
186
|
+
|
187
|
+
exit_status
|
188
|
+
ensure
|
189
|
+
FileUtils.remove_entry(tmp_dir) if tmp_dir
|
190
|
+
end
|
191
|
+
|
192
|
+
# package_operation_and_args can take one of the following forms:
|
193
|
+
# - ["github.com/davidkellis/my-package", "operation1", "arg1:val1", "arg2:val2", "arg3:val3"]
|
194
|
+
# - ["foo.zip", "foo/myfile.ops", "arg1:val1", "arg2:val2", "arg3:val3"]
|
195
|
+
# - ["davidkellis/my-package", "operation1", "arg1:val1", "arg2:val2", "arg3:val3"]
|
196
|
+
# - ["davidkellis/my-package", "operation1"]
|
197
|
+
# - ["my-package/operation1", "arg1:val1", "arg2:val2", "arg3:val3"]
|
198
|
+
# - ["./my-package/operation1", "arg1:val1", "arg2:val2", "arg3:val3"]
|
199
|
+
# - ["../../my-package/operation1", "arg1:val1", "arg2:val2", "arg3:val3"]
|
200
|
+
# - ["../../my-package/operation1"]
|
201
|
+
#
|
202
|
+
# returns 3-tuple of the form: [ ops_file_path, operation_kv_args, optional_tmp_dir ]
|
203
|
+
# such that the third item - optional_tmp_dir - if present, should be deleted after the script has completed running
|
204
|
+
def get_entry_point_ops_file_and_args(package_operation_and_args)
|
205
|
+
package_operation_and_args = package_operation_and_args.dup
|
206
|
+
package_or_ops_file_reference = package_operation_and_args.slice!(0, 1).first
|
207
|
+
tmp_dir = nil
|
208
|
+
|
209
|
+
case
|
210
|
+
when File.exist?(package_or_ops_file_reference)
|
211
|
+
first_filepath = package_or_ops_file_reference.to_pathname.realpath
|
212
|
+
ops_file_path = case first_filepath.extname.downcase
|
213
|
+
when ".ops"
|
214
|
+
first_filepath
|
215
|
+
when ".zip"
|
216
|
+
tmp_dir = Dir.mktmpdir.to_pathname
|
217
|
+
|
218
|
+
# unzip the bundle into the temp directory
|
219
|
+
DirZipper.unzip(first_filepath, tmp_dir)
|
220
|
+
|
221
|
+
relative_ops_path = package_operation_and_args.slice!(0, 1).first
|
222
|
+
|
223
|
+
tmp_dir.join(relative_ops_path)
|
224
|
+
else
|
225
|
+
raise Error, "Unknown file type for entrypoint: #{first_filepath}"
|
226
|
+
end
|
227
|
+
operation_kv_args = package_operation_and_args
|
228
|
+
[ops_file_path, operation_kv_args, tmp_dir ]
|
229
|
+
when repo_url = git_repo?(package_or_ops_file_reference)
|
230
|
+
destination_package_path = bundler.download_git_package(repo_url)
|
231
|
+
|
232
|
+
ops_file_path = nil
|
233
|
+
base_path = Pathname.new(destination_package_path)
|
234
|
+
path_parts = 0
|
235
|
+
package_operation_and_args.each do |candidate_path_arg|
|
236
|
+
candidate_base_path = base_path.join(candidate_path_arg)
|
237
|
+
candidate_ops_file = candidate_base_path.sub_ext(".ops")
|
238
|
+
if candidate_ops_file.exist?
|
239
|
+
path_parts += 1
|
240
|
+
ops_file_path = candidate_ops_file
|
241
|
+
break
|
242
|
+
elsif candidate_base_path.exist?
|
243
|
+
path_parts += 1
|
244
|
+
else
|
245
|
+
raise Error, "Operation not found in #{repo_url}: #{candidate_base_path}"
|
246
|
+
end
|
247
|
+
base_path = candidate_base_path
|
248
|
+
end
|
249
|
+
operation_kv_args = package_operation_and_args.drop(path_parts)
|
250
|
+
|
251
|
+
# for an original package_operation_and_args of ["github.com/davidkellis/my-package", "operation1", "arg1:val1", "arg2:val2", "arg3:val3"]
|
252
|
+
# we return: [ "#{pwd}/#{Bundler::BUNDLE_DIR}/github-com-davidkellis-my-package/operation1.ops", ["arg1:val1", "arg2:val2", "arg3:val3"] ]
|
253
|
+
[ops_file_path, operation_kv_args, tmp_dir]
|
254
|
+
else
|
255
|
+
raise Error, "Unknown operation reference: #{package_or_ops_file_reference.inspect}"
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
# git_repo?("davidkellis/arborist") -> "https://github.com/davidkellis/arborist"
|
260
|
+
# returns the repo URL
|
261
|
+
def git_repo?(repo_reference)
|
262
|
+
candidate_repo_references = [
|
263
|
+
repo_reference,
|
264
|
+
repo_reference =~ /(\.(com|net|org|dev|io|local))\// && "https://#{repo_reference}",
|
265
|
+
repo_reference !~ /github\.com\// && repo_reference =~ /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}\/([\w\.@\:\-~]+)$/i && "https://github.com/#{repo_reference}" # this regex is from https://www.npmjs.com/package/github-username-regex and https://www.debuggex.com/r/H4kRw1G0YPyBFjfm
|
266
|
+
].compact
|
267
|
+
working_repo_reference = candidate_repo_references.find {|reference| Git.ls_remote(reference) rescue nil }
|
268
|
+
working_repo_reference
|
269
|
+
end
|
270
|
+
|
271
|
+
def bundle_status
|
272
|
+
end
|
273
|
+
|
274
|
+
def bundle_update
|
275
|
+
bundler.update
|
276
|
+
end
|
277
|
+
|
278
|
+
def report_inventory(host_references, tags: nil)
|
279
|
+
selected_hosts = inventory(tags, host_references)
|
280
|
+
|
281
|
+
selected_hosts.each do |host|
|
282
|
+
puts host.summary(verbose?)
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
# tag_selection is an array of strings
|
287
|
+
def inventory(tag_selection = nil, host_references_override = nil)
|
288
|
+
host_references = host_references_override || @inventory_host_references
|
289
|
+
tags = @inventory_tag_selections + (tag_selection || [])
|
290
|
+
tags.uniq!
|
291
|
+
|
292
|
+
host_references = ["hosts.yml"] if (host_references.nil? || host_references.empty?) && File.exist?("hosts.yml")
|
293
|
+
|
294
|
+
hosts_files, host_strings = host_references.partition {|ref| File.exist?(ref) }
|
295
|
+
hosts_files = hosts_files.map {|file_path| HostsFile.new(file_path) }
|
296
|
+
untagged_hosts = host_strings.map(&:strip).uniq.map {|host| Host.new(host) }
|
297
|
+
inventory_file_hosts = hosts_files.reduce({}) do |host_map, hosts_file|
|
298
|
+
hosts_file.hosts.each do |host|
|
299
|
+
(host_map[host] ||= host).tag!(host.tags)
|
300
|
+
end
|
301
|
+
|
302
|
+
host_map
|
303
|
+
end.keys
|
304
|
+
all_hosts = untagged_hosts + inventory_file_hosts
|
305
|
+
|
306
|
+
selected_hosts = if tags.empty?
|
307
|
+
all_hosts
|
308
|
+
else
|
309
|
+
all_hosts.select do |host|
|
310
|
+
tags.all? {|t| host.tags.include? t }
|
311
|
+
end
|
312
|
+
end
|
313
|
+
|
314
|
+
selected_hosts.sort_by(&:to_s)
|
315
|
+
end
|
316
|
+
|
317
|
+
def unzip(zip_bundle_file = nil, output_dir = nil)
|
318
|
+
bundler.unzip(zip_bundle_file, output_dir)
|
319
|
+
end
|
320
|
+
|
321
|
+
def zip
|
322
|
+
tmpzip = pwd.join("tmpops.zip")
|
323
|
+
FileUtils.rm(tmpzip) if tmpzip.exist?
|
324
|
+
@zip_bundle_path ||= DirZipper.zip(pwd, tmpzip)
|
325
|
+
end
|
326
|
+
|
327
|
+
end
|
328
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
# if brew is already installed, initialize this shell environment with brew
|
4
|
+
if [ -x "$(command -v /home/linuxbrew/.linuxbrew/bin/brew)" ]; then
|
5
|
+
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
|
6
|
+
|
7
|
+
# exit early if ruby already exists
|
8
|
+
if [ -x "$(command -v ruby)" ]; then
|
9
|
+
echo 'Ruby is already installed.' >&2
|
10
|
+
exit 0
|
11
|
+
fi
|
12
|
+
fi
|
13
|
+
|
14
|
+
OS=$(cat /etc/os-release | grep "^ID=")
|
15
|
+
if echo $OS | grep -q 'ubuntu'; then
|
16
|
+
# update package list
|
17
|
+
sudo apt update -qy
|
18
|
+
|
19
|
+
if [ -f /var/run/reboot-required ]; then
|
20
|
+
echo 'A system reboot is required!'
|
21
|
+
exit 1
|
22
|
+
fi
|
23
|
+
|
24
|
+
# there are probably some services that need restarting because they're using old libraries, so we'll just do the easy thing and reboot
|
25
|
+
sudo DEBIAN_FRONTEND=noninteractive apt install -yq needrestart
|
26
|
+
|
27
|
+
# install homebrew dependencies, per https://docs.brew.sh/Homebrew-on-Linux
|
28
|
+
sudo DEBIAN_FRONTEND=noninteractive apt-get install -yq build-essential procps curl file git
|
29
|
+
|
30
|
+
# restart services that need it
|
31
|
+
sudo needrestart -q -r a
|
32
|
+
sudo needrestart -q -r a
|
33
|
+
sudo needrestart -q -r a
|
34
|
+
elif echo $OS | grep -q 'fedora'; then
|
35
|
+
sudo yum groupinstall -y 'Development Tools'
|
36
|
+
sudo yum install -y procps-ng curl file git
|
37
|
+
elif echo $OS | grep -q 'arch'; then
|
38
|
+
sudo pacman -Syu --noconfirm base-devel procps-ng curl file git
|
39
|
+
else
|
40
|
+
echo "unsupported OS"
|
41
|
+
exit 1
|
42
|
+
fi
|
43
|
+
|
44
|
+
|
45
|
+
# install homebrew
|
46
|
+
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
47
|
+
|
48
|
+
# initialize brew in shell session
|
49
|
+
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
|
50
|
+
|
51
|
+
# install gcc, ruby, age
|
52
|
+
brew install gcc
|
53
|
+
brew install ruby
|
54
|
+
brew install age # https://github.com/FiloSottile/age
|
55
|
+
|
56
|
+
# install opswalrus gem
|
57
|
+
gem install opswalrus
|
@@ -0,0 +1,12 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
# update package list
|
4
|
+
sudo apt update -y
|
5
|
+
|
6
|
+
# update OS
|
7
|
+
# sudo DEBIAN_FRONTEND=noninteractive apt-get -o Dpkg::Options::="--force-confold" -o Dpkg::Options::="--force-confdef" dist-upgrade -q -y --allow-downgrades --allow-remove-essential --allow-change-held-packages
|
8
|
+
|
9
|
+
if [ -f /var/run/reboot-required ]; then
|
10
|
+
echo 'A system reboot is required!'
|
11
|
+
sudo reboot
|
12
|
+
fi
|