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