opswalrus 1.0.31 → 1.0.33
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Dockerfile +1 -1
- data/Gemfile.lock +1 -1
- data/README.md +3 -1
- data/build.ops +1 -1
- data/lib/opswalrus/app.rb +29 -21
- data/lib/opswalrus/bootstrap.sh +1 -1
- data/lib/opswalrus/cli.rb +85 -12
- data/lib/opswalrus/host.rb +75 -12
- data/lib/opswalrus/hosts_file.rb +355 -4
- data/lib/opswalrus/inventory.rb +41 -0
- data/lib/opswalrus/version.rb +1 -1
- data/vms/web-arch/Vagrantfile +13 -7
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 030db97da3b6d068117676d69923976f0813583cb95a8e5a7e7d9eb500992a33
|
4
|
+
data.tar.gz: dd14df25bd90ac123ad064d10ed3d32a18079766d74e97344c9c0998263a3669
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fa3ca11fec12097b0deb614829f4e5919c9e64b8ccf8602d0f4faa3969105cc1768129d839120a81a88dff1242e4106951d059782f500bef1a59689ea90a5d0d
|
7
|
+
data.tar.gz: b93688cb0fdd43b19581b5bae68e113aea68e67e6ccfd62704d05d868a250fb6224b36d39aeda2c06a467d6a1900967f2c7c4b095fa6a40b01a1c52d10017955
|
data/Dockerfile
CHANGED
@@ -16,7 +16,7 @@ COPY Gemfile Gemfile.lock opswalrus.gemspec ./
|
|
16
16
|
COPY lib/opswalrus/version.rb /opswalrus/lib/opswalrus/version.rb
|
17
17
|
|
18
18
|
# Install system dependencies
|
19
|
-
RUN apk add --no-cache --update build-base git docker openrc openssh-client-default \
|
19
|
+
RUN apk add --no-cache --update build-base git docker openrc openssh-client-default age \
|
20
20
|
&& rc-update add docker boot \
|
21
21
|
&& gem install bundler --version=2.4.3 \
|
22
22
|
&& bundle install
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -19,8 +19,10 @@ You have two options:
|
|
19
19
|
|
20
20
|
## Docker install
|
21
21
|
|
22
|
+
The path to whatever directory you store your age identity files in should be used in place of `$HOME/.secrets/age` in the following shell alias:
|
23
|
+
|
22
24
|
```shell
|
23
|
-
❯ alias ops='docker run --rm -it -v $HOME/.ssh:/root/.ssh -v /var/run/docker.sock:/var/run/docker.sock -v ${PWD}/:/workdir ghcr.io/opswalrus/ops'
|
25
|
+
❯ alias ops='docker run --rm -it -v $HOME/.secrets/age:/root/.secrets -e OPSWALRUS_AGE_IDS="/root/.secrets/*" -v $HOME/.ssh:/root/.ssh -v /var/run/docker.sock:/var/run/docker.sock -v ${PWD}/:/workdir ghcr.io/opswalrus/ops'
|
24
26
|
|
25
27
|
❯ ops version
|
26
28
|
1.0.13
|
data/build.ops
CHANGED
@@ -49,7 +49,7 @@ bw_status_output = bw_status_output.gsub('mac failed.', '').strip
|
|
49
49
|
bw_status_json = bw_status_output.parse_json
|
50
50
|
|
51
51
|
if bw_status_json['status'] != 'unlocked'
|
52
|
-
exit
|
52
|
+
exit 1, "Bitwarden is not unlocked. Please unlock bitwarden with: bw unlock"
|
53
53
|
end
|
54
54
|
|
55
55
|
totp = sh("Get Rubygems OTP") { 'bw get totp Rubygems' }
|
data/lib/opswalrus/app.rb
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
require "citrus"
|
2
2
|
require "io/console"
|
3
3
|
require "json"
|
4
|
-
# require "logger"
|
5
4
|
require "random/formatter"
|
6
5
|
require "pastel"
|
7
6
|
require "pathname"
|
@@ -10,11 +9,14 @@ require "shellwords"
|
|
10
9
|
require "socket"
|
11
10
|
require "stringio"
|
12
11
|
require "yaml"
|
13
|
-
|
12
|
+
|
14
13
|
require_relative "patches"
|
14
|
+
|
15
|
+
require_relative "errors"
|
15
16
|
require_relative "git"
|
16
17
|
require_relative "host"
|
17
18
|
require_relative "hosts_file"
|
19
|
+
require_relative "inventory"
|
18
20
|
require_relative "operation_runner"
|
19
21
|
require_relative "bundler"
|
20
22
|
require_relative "package_file"
|
@@ -33,6 +35,7 @@ module OpsWalrus
|
|
33
35
|
|
34
36
|
|
35
37
|
attr_reader :local_hostname
|
38
|
+
attr_reader :identity_file_paths
|
36
39
|
|
37
40
|
def initialize(pwd = Dir.pwd)
|
38
41
|
SemanticLogger.default_level = :info
|
@@ -49,6 +52,7 @@ module OpsWalrus
|
|
49
52
|
@verbose = false
|
50
53
|
@sudo_user = nil
|
51
54
|
@sudo_password = nil
|
55
|
+
@identity_file_paths = []
|
52
56
|
@inventory_host_references = []
|
53
57
|
@inventory_tag_selections = []
|
54
58
|
@params = nil
|
@@ -93,6 +97,10 @@ module OpsWalrus
|
|
93
97
|
@local_hostname = hostname.empty? ? "localhost" : hostname
|
94
98
|
end
|
95
99
|
|
100
|
+
def set_identity_files(*paths)
|
101
|
+
@identity_file_paths = paths.flatten.compact.uniq
|
102
|
+
end
|
103
|
+
|
96
104
|
def set_inventory_hosts(*hosts)
|
97
105
|
hosts.flatten!.compact!
|
98
106
|
@inventory_host_references.concat(hosts).compact!
|
@@ -381,29 +389,29 @@ module OpsWalrus
|
|
381
389
|
tags = @inventory_tag_selections + (tag_selection || [])
|
382
390
|
tags.uniq!
|
383
391
|
|
384
|
-
host_references = [
|
392
|
+
host_references = [HostsFile::DEFAULT_FILE_NAME] if (host_references.nil? || host_references.empty?) && File.exist?(HostsFile::DEFAULT_FILE_NAME)
|
385
393
|
|
386
|
-
|
387
|
-
|
388
|
-
untagged_hosts = host_strings.map(&:strip).uniq.map {|host| Host.new(host) }
|
389
|
-
inventory_file_hosts = hosts_files.reduce({}) do |host_map, hosts_file|
|
390
|
-
hosts_file.hosts.each do |host|
|
391
|
-
(host_map[host] ||= host).tag!(host.tags)
|
392
|
-
end
|
394
|
+
Inventory.new(host_references, tags).hosts
|
395
|
+
end
|
393
396
|
|
394
|
-
|
395
|
-
|
396
|
-
all_hosts = untagged_hosts + inventory_file_hosts
|
397
|
+
def edit_inventory(file_path)
|
398
|
+
raise "File not found: #{file_path}" unless File.exist?(file_path)
|
397
399
|
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
400
|
+
HostsFile.edit(file_path)
|
401
|
+
end
|
402
|
+
|
403
|
+
def encrypt_inventory(file_path, output_file_path)
|
404
|
+
raise "File not found: #{file_path}" unless File.exist?(file_path)
|
405
|
+
|
406
|
+
hosts_file = HostsFile.new(file_path)
|
407
|
+
hosts_file.encrypt(output_file_path)
|
408
|
+
end
|
409
|
+
|
410
|
+
def decrypt_inventory(file_path, output_file_path)
|
411
|
+
raise "File not found: #{file_path}" unless File.exist?(file_path)
|
405
412
|
|
406
|
-
|
413
|
+
hosts_file = HostsFile.new(file_path)
|
414
|
+
hosts_file.decrypt(output_file_path)
|
407
415
|
end
|
408
416
|
|
409
417
|
def print_version
|
data/lib/opswalrus/bootstrap.sh
CHANGED
@@ -14,7 +14,7 @@ GEM_CMD=$RTX_GEM
|
|
14
14
|
|
15
15
|
if [ -x $RTX ]; then
|
16
16
|
# rtx_init;
|
17
|
-
# eval "$(
|
17
|
+
# eval "$(rtx activate bash)"
|
18
18
|
# if brew is already installed, initialize this shell environment with brew
|
19
19
|
# if [ -x "$(command -v /home/linuxbrew/.linuxbrew/bin/brew)" ]; then
|
20
20
|
if $RUBY_CMD -e "major, minor, patch = RUBY_VERSION.split('.'); exit 1 unless major.to_i >= 3"; then
|
data/lib/opswalrus/cli.rb
CHANGED
@@ -4,6 +4,11 @@ require "gli"
|
|
4
4
|
require_relative "app"
|
5
5
|
|
6
6
|
module OpsWalrus
|
7
|
+
def self.env_specified_age_ids()
|
8
|
+
# ENV['AGE_ID'] || (ENV['OPSWALRUS_AGE_IDS'] && Dir.glob(ENV['OPSWALRUS_AGE_IDS']))
|
9
|
+
ENV['OPSWALRUS_AGE_IDS'] && Dir.glob(ENV['OPSWALRUS_AGE_IDS'])
|
10
|
+
end
|
11
|
+
|
7
12
|
class Cli
|
8
13
|
extend GLI::App
|
9
14
|
|
@@ -37,6 +42,7 @@ module OpsWalrus
|
|
37
42
|
|
38
43
|
flag [:h, :hosts], multiple: true, desc: "Specify the hosts.yaml file"
|
39
44
|
flag [:t, :tags], multiple: true, desc: "Specify a set of tags to filter the hosts by"
|
45
|
+
flag [:i, :id], multiple: true, desc: "Specify one or more Age Encryption identify files (private keys)"
|
40
46
|
|
41
47
|
desc 'Print version'
|
42
48
|
command :version do |c|
|
@@ -45,18 +51,75 @@ module OpsWalrus
|
|
45
51
|
end
|
46
52
|
end
|
47
53
|
|
48
|
-
desc '
|
49
|
-
long_desc '
|
54
|
+
desc 'View and edit host inventory'
|
55
|
+
long_desc 'View and edit host inventory'
|
50
56
|
command :inventory do |c|
|
51
|
-
c.action do |global_options, options, args|
|
52
|
-
hosts = global_options[:hosts] || []
|
53
|
-
tags = global_options[:tags] || []
|
54
57
|
|
55
|
-
|
56
|
-
|
58
|
+
desc 'List hosts in inventory'
|
59
|
+
long_desc 'List hosts in inventory'
|
60
|
+
c.command [:ls, :list] do |list|
|
61
|
+
list.action do |global_options, options, args|
|
62
|
+
|
63
|
+
hosts = global_options[:hosts]
|
64
|
+
tags = global_options[:tags]
|
65
|
+
|
66
|
+
log_level = global_options[:debug] && :trace || global_options[:verbose] && :debug || :info
|
67
|
+
$app.set_log_level(log_level)
|
68
|
+
|
69
|
+
$app.report_inventory(hosts, tags: tags)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
desc 'Edit hosts in inventory'
|
74
|
+
long_desc 'Edit the hosts in the inventory and their secrets'
|
75
|
+
# arg_name 'hosts_file', :optional
|
76
|
+
c.command :edit do |edit|
|
77
|
+
edit.action do |global_options, options, args|
|
78
|
+
file_path = global_options[:hosts].first || HostsFile::DEFAULT_FILE_NAME
|
79
|
+
|
80
|
+
id_files = global_options[:id]
|
81
|
+
id_files = OpsWalrus.env_specified_age_ids if id_files.empty?
|
82
|
+
$app.set_identity_files(id_files)
|
83
|
+
|
84
|
+
$app.edit_inventory(file_path)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
desc 'Encrypt secrets in inventory file'
|
89
|
+
long_desc 'Encrypt secrets in inventory file'
|
90
|
+
arg_name 'encrypted_host_file_path', :optional
|
91
|
+
c.command :encrypt do |encrypt|
|
92
|
+
encrypt.action do |global_options, options, args|
|
93
|
+
file_path = global_options[:hosts].first || HostsFile::DEFAULT_FILE_NAME
|
94
|
+
output_file_path = args.first || file_path
|
95
|
+
|
96
|
+
id_files = global_options[:id]
|
97
|
+
id_files = OpsWalrus.env_specified_age_ids if id_files.empty?
|
98
|
+
|
99
|
+
$app.set_identity_files(id_files)
|
100
|
+
|
101
|
+
$app.encrypt_inventory(file_path, output_file_path)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
desc 'Decrypt secrets in inventory file'
|
106
|
+
long_desc 'Decrypt secrets in inventory file'
|
107
|
+
arg_name 'decrypted_host_file_path', :optional
|
108
|
+
c.command :decrypt do |decrypt|
|
109
|
+
decrypt.action do |global_options, options, args|
|
110
|
+
file_path = global_options[:hosts].first || HostsFile::DEFAULT_FILE_NAME
|
111
|
+
output_file_path = args.first || file_path
|
57
112
|
|
58
|
-
|
113
|
+
id_files = global_options[:id]
|
114
|
+
id_files = OpsWalrus.env_specified_age_ids if id_files.empty?
|
115
|
+
|
116
|
+
$app.set_identity_files(id_files)
|
117
|
+
|
118
|
+
$app.decrypt_inventory(file_path, output_file_path)
|
119
|
+
end
|
59
120
|
end
|
121
|
+
|
122
|
+
c.default_command :list
|
60
123
|
end
|
61
124
|
|
62
125
|
desc 'Bootstrap a set of hosts to run opswalrus'
|
@@ -71,12 +134,17 @@ module OpsWalrus
|
|
71
134
|
log_level = global_options[:debug] && :trace || global_options[:verbose] && :debug || :info
|
72
135
|
$app.set_log_level(log_level)
|
73
136
|
|
74
|
-
hosts = global_options[:hosts]
|
75
|
-
tags = global_options[:tags]
|
137
|
+
hosts = global_options[:hosts]
|
138
|
+
tags = global_options[:tags]
|
76
139
|
|
77
140
|
$app.set_inventory_hosts(hosts)
|
78
141
|
$app.set_inventory_tags(tags)
|
79
142
|
|
143
|
+
id_files = global_options[:id]
|
144
|
+
id_files = OpsWalrus.env_specified_age_ids if id_files.empty?
|
145
|
+
|
146
|
+
$app.set_identity_files(id_files)
|
147
|
+
|
80
148
|
dry_run = [:noop, :dryrun, :dry_run].any? {|sym| global_options[sym] || options[sym] }
|
81
149
|
$app.dry_run! if dry_run
|
82
150
|
|
@@ -102,8 +170,8 @@ module OpsWalrus
|
|
102
170
|
log_level = global_options[:debug] && :trace || global_options[:verbose] && :debug || :info
|
103
171
|
$app.set_log_level(log_level)
|
104
172
|
|
105
|
-
hosts = global_options[:hosts]
|
106
|
-
tags = global_options[:tags]
|
173
|
+
hosts = global_options[:hosts]
|
174
|
+
tags = global_options[:tags]
|
107
175
|
|
108
176
|
$app.set_inventory_hosts(hosts)
|
109
177
|
$app.set_inventory_tags(tags)
|
@@ -115,6 +183,11 @@ module OpsWalrus
|
|
115
183
|
|
116
184
|
$app.set_sudo_user(user) if user
|
117
185
|
|
186
|
+
id_files = global_options[:id]
|
187
|
+
id_files = OpsWalrus.env_specified_age_ids if id_files.empty?
|
188
|
+
|
189
|
+
$app.set_identity_files(id_files)
|
190
|
+
|
118
191
|
dry_run = [:noop, :dryrun, :dry_run].any? {|sym| global_options[sym] || options[sym] }
|
119
192
|
$app.dry_run! if dry_run
|
120
193
|
|
data/lib/opswalrus/host.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
require "set"
|
2
2
|
require "sshkit"
|
3
|
+
require "tempfile"
|
3
4
|
|
4
5
|
require_relative "interaction_handlers"
|
5
6
|
require_relative "invocation"
|
@@ -207,30 +208,52 @@ module OpsWalrus
|
|
207
208
|
class Host
|
208
209
|
include HostDSL
|
209
210
|
|
210
|
-
def initialize(name_or_ip_or_cidr, tags = [], props = {})
|
211
|
+
def initialize(name_or_ip_or_cidr, tags = [], props = {}, default_props = {}, hosts_file)
|
211
212
|
@name_or_ip_or_cidr = name_or_ip_or_cidr
|
212
213
|
@tags = tags.to_set
|
213
214
|
@props = props.is_a?(Array) ? {"tags" => props} : props.to_h
|
215
|
+
@default_props = default_props
|
216
|
+
@hosts_file = hosts_file
|
217
|
+
@tmp_ssh_key_files = []
|
218
|
+
end
|
219
|
+
|
220
|
+
# secret_ref: SecretRef
|
221
|
+
# returns the decrypted value referenced by the supplied SecretRef
|
222
|
+
def dereference_secret_if_needed(secret_ref)
|
223
|
+
if secret_ref.is_a? SecretRef
|
224
|
+
@hosts_file.read_secret(secret_ref.to_s)
|
225
|
+
else
|
226
|
+
secret_ref
|
227
|
+
end
|
214
228
|
end
|
215
229
|
|
216
230
|
def host
|
217
231
|
@name_or_ip_or_cidr
|
218
232
|
end
|
219
233
|
|
234
|
+
def alias
|
235
|
+
@props["alias"] || @default_props["alias"]
|
236
|
+
end
|
237
|
+
|
238
|
+
def ignore?
|
239
|
+
@props["ignore"] || @default_props["ignore"]
|
240
|
+
end
|
241
|
+
|
220
242
|
def ssh_port
|
221
|
-
@props["port"]
|
243
|
+
@props["port"] || @default_props["port"]
|
222
244
|
end
|
223
245
|
|
224
246
|
def ssh_user
|
225
|
-
@props["user"]
|
247
|
+
@props["user"] || @default_props["user"]
|
226
248
|
end
|
227
249
|
|
228
250
|
def ssh_password
|
229
|
-
@props["password"]
|
251
|
+
password = @props["password"] || @default_props["password"]
|
252
|
+
dereference_secret_if_needed(password)
|
230
253
|
end
|
231
254
|
|
232
|
-
def
|
233
|
-
@props["
|
255
|
+
def ssh_key
|
256
|
+
@props["ssh-key"] || @default_props["ssh-key"]
|
234
257
|
end
|
235
258
|
|
236
259
|
def hash
|
@@ -245,10 +268,6 @@ module OpsWalrus
|
|
245
268
|
@name_or_ip_or_cidr
|
246
269
|
end
|
247
270
|
|
248
|
-
def alias
|
249
|
-
@props["alias"]
|
250
|
-
end
|
251
|
-
|
252
271
|
def tag!(*tags)
|
253
272
|
enumerables, scalars = tags.partition {|t| Enumerable === t }
|
254
273
|
@tags.merge(scalars)
|
@@ -263,21 +282,52 @@ module OpsWalrus
|
|
263
282
|
def summary(verbose = false)
|
264
283
|
report = "#{to_s}\n tags: #{tags.sort.join(', ')}"
|
265
284
|
if verbose
|
266
|
-
@props.reject{|k,v| k == 'tags' }.each {|k,v| report << "\n #{k}: #{v}" }
|
285
|
+
@default_props.merge(@props).reject{|k,v| k == 'tags' }.each {|k,v| report << "\n #{k}: #{v}" }
|
267
286
|
end
|
268
287
|
report
|
269
288
|
end
|
270
289
|
|
271
290
|
def sshkit_host
|
291
|
+
keys = case ssh_key
|
292
|
+
when Array
|
293
|
+
ssh_key
|
294
|
+
else
|
295
|
+
[ssh_key]
|
296
|
+
end
|
297
|
+
keys = write_temp_ssh_keys_if_needed(keys)
|
298
|
+
|
299
|
+
# the various options for net-ssh are captured in https://net-ssh.github.io/ssh/v1/chapter-2.html
|
272
300
|
@sshkit_host ||= ::SSHKit::Host.new({
|
273
301
|
hostname: host,
|
274
302
|
port: ssh_port || 22,
|
275
303
|
user: ssh_user || raise("No ssh user specified to connect to #{host}"),
|
276
304
|
password: ssh_password,
|
277
|
-
keys:
|
305
|
+
keys: keys
|
278
306
|
})
|
279
307
|
end
|
280
308
|
|
309
|
+
# keys is an Array ( String | SecretRef )
|
310
|
+
# such that if a key is a String, then it is interpreted as a path to a key file
|
311
|
+
# and if the key is a SecretRef, then the secret's plaintext value is interpreted
|
312
|
+
# as an ssh key string, and must thereforce be written to a tempfile so that net-ssh
|
313
|
+
# can use it via file reference (since net-ssh only allows the keys field to be an array of file paths).
|
314
|
+
#
|
315
|
+
# returns an array of file paths to key files
|
316
|
+
def write_temp_ssh_keys_if_needed(keys)
|
317
|
+
keys.map do |key_file_path_or_in_memory_key_text|
|
318
|
+
if key_file_path_or_in_memory_key_text.is_a? SecretRef # we're dealing with an in-memory key file; we need to write it to a tempfile
|
319
|
+
tempfile = Tempfile.new
|
320
|
+
@tmp_ssh_key_files << tempfile
|
321
|
+
key_file_contents = @hosts_file.read_secret(key_file_path_or_in_memory_key_text.to_s)
|
322
|
+
tempfile.write(key_file_contents)
|
323
|
+
tempfile.close(false) # we want to close the file without unlinking so that the editor can write to it
|
324
|
+
tempfile.path
|
325
|
+
else # we're dealing with a reference to a keyfile - a path - so return it
|
326
|
+
key_file_path_or_in_memory_key_text
|
327
|
+
end
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
281
331
|
def set_runtime_env(runtime_env)
|
282
332
|
@runtime_env = runtime_env
|
283
333
|
end
|
@@ -294,6 +344,8 @@ module OpsWalrus
|
|
294
344
|
@runtime_env = nil
|
295
345
|
@sshkit_backend = nil
|
296
346
|
@tmp_bundle_root_dir = nil
|
347
|
+
@tmp_ssh_key_files.each {|tmpfile| tmpfile.close() rescue nil; tmpfile.unlink() rescue nil }
|
348
|
+
@tmp_ssh_key_files = []
|
297
349
|
end
|
298
350
|
|
299
351
|
def execute(*args, input: nil)
|
@@ -317,6 +369,17 @@ module OpsWalrus
|
|
317
369
|
@sshkit_backend.download!(remote_path.to_s, local_path.to_s)
|
318
370
|
end
|
319
371
|
|
372
|
+
def to_h
|
373
|
+
hash = {}
|
374
|
+
hash["alias"] = @props["alias"] if @props["alias"]
|
375
|
+
hash["ignore"] = @props["ignore"] if @props["ignore"]
|
376
|
+
hash["user"] = @props["user"] if @props["user"]
|
377
|
+
hash["port"] = @props["port"] if @props["port"]
|
378
|
+
hash["password"] = @props["password"] if @props["password"]
|
379
|
+
hash["ssh-key"] = @props["ssh-key"] if @props["ssh-key"]
|
380
|
+
hash["tags"] = tags.to_a unless tags.empty?
|
381
|
+
hash
|
382
|
+
end
|
320
383
|
end
|
321
384
|
|
322
385
|
end
|
data/lib/opswalrus/hosts_file.rb
CHANGED
@@ -1,16 +1,39 @@
|
|
1
|
+
require "open3"
|
1
2
|
require "pathname"
|
3
|
+
require "psych"
|
4
|
+
require "stringio"
|
5
|
+
require "tempfile"
|
6
|
+
require "tty-editor"
|
2
7
|
require "yaml"
|
3
8
|
require_relative "host"
|
4
9
|
|
5
10
|
module OpsWalrus
|
11
|
+
|
6
12
|
class HostsFile
|
13
|
+
def self.edit(file_path)
|
14
|
+
tempfile = Tempfile.new
|
15
|
+
begin
|
16
|
+
tempfile.close(false) # we want to close the file without unlinking so that the editor can write to it
|
17
|
+
HostsFile.new(file_path).decrypt(tempfile.path)
|
18
|
+
if TTY::Editor.open(tempfile.path)
|
19
|
+
# tempfile.open()
|
20
|
+
HostsFile.new(tempfile.path).encrypt(file_path)
|
21
|
+
end
|
22
|
+
ensure
|
23
|
+
tempfile.close rescue nil
|
24
|
+
tempfile.unlink # deletes the temp file
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
DEFAULT_FILE_NAME = "hosts.yaml"
|
29
|
+
|
7
30
|
attr_accessor :hosts_file_path
|
8
31
|
attr_accessor :yaml
|
9
32
|
|
10
33
|
def initialize(hosts_file_path)
|
11
34
|
@hosts_file_path = File.absolute_path(hosts_file_path)
|
12
|
-
@yaml =
|
13
|
-
|
35
|
+
@yaml = Psych.safe_load(File.read(hosts_file_path), permitted_classes: [SecretRef]) if File.exist?(hosts_file_path)
|
36
|
+
@cipher = AgeEncryptionCipher.new(ids, App.instance.identity_file_paths)
|
14
37
|
end
|
15
38
|
|
16
39
|
def defaults
|
@@ -31,14 +54,74 @@ module OpsWalrus
|
|
31
54
|
# "192.168.56.10"=>{"tags"=>["web", "vagrant"]}
|
32
55
|
# }
|
33
56
|
@yaml.map do |host_ref, host_attrs|
|
34
|
-
next if
|
57
|
+
next if ['default', 'defaults', 'secrets', 'ids'].include?(host_ref)
|
35
58
|
|
36
59
|
host_params = host_attrs.is_a?(Hash) ? host_attrs : {}
|
37
60
|
|
38
|
-
Host.new(host_ref, tags(host_ref), defaults
|
61
|
+
Host.new(host_ref, tags(host_ref), host_params, defaults, self)
|
39
62
|
end.compact
|
40
63
|
end
|
41
64
|
|
65
|
+
# secrets are key/value pairs in which the key is an identifier used throughout the yaml file to reference the secret's value
|
66
|
+
# and the associated value is either a Hash or a String.
|
67
|
+
# 1. If the secret's value is a Hash, then the Hash must consist of two keys - ids and a secret value:
|
68
|
+
# - the ids field explicitly names the intended audience for the secret value.
|
69
|
+
# The value associated with the ids field is either a String value structured as a comma delimited list of ids,
|
70
|
+
# in which each id is a reference to an id contained within the ids section of the inventory file
|
71
|
+
# OR
|
72
|
+
# the ids field is an Array value in which each element of the array is a reference to an id contained within
|
73
|
+
# the ids section of the inventory file.
|
74
|
+
# - the value field is a String value storing the secret value
|
75
|
+
# 2. If the secret's value is a String, then the string is the secret value, and is interpreted to be intended
|
76
|
+
# for use by an audience consisting of all of the ids listed in the ids section of the inventory file.
|
77
|
+
#
|
78
|
+
# returns a Hash of secret-name/Secret pairs
|
79
|
+
def secrets
|
80
|
+
@secrets ||= (@yaml["secrets"] || {}).map do |secret_name, secret_attrs|
|
81
|
+
audience_ids, secret_value = case secret_attrs
|
82
|
+
when Hash
|
83
|
+
id_names = case ids_value = secret_attrs["ids"]
|
84
|
+
when String
|
85
|
+
ids_value.split(',').map(&:strip)
|
86
|
+
when Array
|
87
|
+
ids_value.map {|elem| elem.to_s.strip }
|
88
|
+
else
|
89
|
+
raise "ids field beloning to secret '#{secret_name}' is of an unknown type: #{ids_value.class.name}: #{ids_value.inspect}"
|
90
|
+
end
|
91
|
+
value = secret_attrs["value"]
|
92
|
+
[id_names, value]
|
93
|
+
when String
|
94
|
+
id_names = self.ids.select {|k,id_public_key_or_array_of_id_names| PublicKey === id_public_key_or_array_of_id_names }.keys
|
95
|
+
value = secret_attrs
|
96
|
+
[id_names, value]
|
97
|
+
else
|
98
|
+
raise "Secret '#{secret_name}' has an unexpected type #{secret_attrs.class.name}: #{secret_attrs.inspect}"
|
99
|
+
end
|
100
|
+
|
101
|
+
[secret_name, Secret.new(secret_name, secret_value, audience_ids)]
|
102
|
+
end.to_h
|
103
|
+
end
|
104
|
+
|
105
|
+
# returns a Hash of id-name/(PublicKey | Array String ) pairs
|
106
|
+
def ids
|
107
|
+
@ids ||= begin
|
108
|
+
id_public_key_pairs, alias_id_set_pairs = (@yaml["ids"] || {}).partition{|k,v| String === v }.map(&:to_h)
|
109
|
+
|
110
|
+
named_public_keys = id_public_key_pairs.map do |id_name, public_key_string|
|
111
|
+
[id_name, PublicKey.new(id_name, public_key_string)]
|
112
|
+
end.to_h
|
113
|
+
|
114
|
+
# named_id_sets = alias_id_set_pairs.map do |id_name, id_array|
|
115
|
+
# referenced_public_keys = id_array.map {|id| named_public_keys[id] }.uniq.compact
|
116
|
+
# [id_name, referenced_public_keys]
|
117
|
+
# end.to_h
|
118
|
+
|
119
|
+
# named_public_keys.merge(named_id_sets)
|
120
|
+
|
121
|
+
named_public_keys.merge(alias_id_set_pairs)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
42
125
|
def tags(host)
|
43
126
|
host_attrs = @yaml[host]
|
44
127
|
|
@@ -51,5 +134,273 @@ module OpsWalrus
|
|
51
134
|
tags.compact.uniq
|
52
135
|
end || []
|
53
136
|
end
|
137
|
+
|
138
|
+
def to_yaml
|
139
|
+
hash = {}
|
140
|
+
hash["defaults"] = defaults unless defaults.empty?
|
141
|
+
hosts.each do |host|
|
142
|
+
hash[host.host] = host.to_h
|
143
|
+
end
|
144
|
+
hash["secrets"] = secrets
|
145
|
+
hash["ids"] = ids
|
146
|
+
|
147
|
+
yaml = Psych.safe_dump(hash, permitted_classes: [SecretRef, Secret, PublicKey])
|
148
|
+
yaml.sub(/^---\s*/,"") # omit the leading line: ---\n
|
149
|
+
end
|
150
|
+
|
151
|
+
# returns the decrypted value referenced by secret_name
|
152
|
+
def read_secret(secret_name)
|
153
|
+
secret = secrets[secret_name]
|
154
|
+
secret.decrypt(@cipher)
|
155
|
+
end
|
156
|
+
|
157
|
+
def encrypt_secrets!()
|
158
|
+
secrets.each do |secret_name, secret|
|
159
|
+
secret.encrypt(@cipher)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def decrypt_secrets!()
|
164
|
+
secrets.each do |secret_name, secret|
|
165
|
+
secret.decrypt(@cipher)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def decrypt(decrypted_file_path = nil)
|
170
|
+
decrypted_file_path ||= @hosts_file_path
|
171
|
+
puts "Decrypting #{@hosts_file_path} -> #{decrypted_file_path}."
|
172
|
+
raise("Path to age identity not specified") if App.instance.identity_file_paths.empty?
|
173
|
+
decrypt_secrets!
|
174
|
+
File.write(decrypted_file_path, to_yaml)
|
175
|
+
# puts to_yaml
|
176
|
+
end
|
177
|
+
|
178
|
+
def encrypt(encrypted_file_path = nil)
|
179
|
+
encrypted_file_path ||= @hosts_file_path
|
180
|
+
puts "Encrypting #{@hosts_file_path} -> #{encrypted_file_path}."
|
181
|
+
raise("Path to age identity not specified") if App.instance.identity_file_paths.empty?
|
182
|
+
encrypt_secrets!
|
183
|
+
File.write(encrypted_file_path, to_yaml)
|
184
|
+
# puts to_yaml
|
185
|
+
end
|
54
186
|
end
|
187
|
+
|
188
|
+
class PublicKey
|
189
|
+
# yaml_tag nil
|
190
|
+
|
191
|
+
def initialize(id_name, public_key)
|
192
|
+
@id = id_name
|
193
|
+
@public_key = public_key
|
194
|
+
end
|
195
|
+
|
196
|
+
def key
|
197
|
+
@public_key
|
198
|
+
end
|
199
|
+
|
200
|
+
# #init_with and #encode_with are demonstrated here:
|
201
|
+
# - https://djellemah.com/blog/2014/07/23/yaml-deserialisation/
|
202
|
+
# - https://stackoverflow.com/questions/10629209/how-can-i-control-which-fields-to-serialize-with-yaml
|
203
|
+
# - https://github.com/protocolbuffers/protobuf/issues/4391
|
204
|
+
# serialise to yaml
|
205
|
+
def encode_with(coder)
|
206
|
+
# per https://rubydoc.info/stdlib/psych/3.0.0/Psych/Coder#scalar-instance_method
|
207
|
+
coder.represent_scalar(nil, @public_key)
|
208
|
+
|
209
|
+
# coder.scalar = @public_key
|
210
|
+
# coder['public_key'] = @public_key
|
211
|
+
end
|
212
|
+
|
213
|
+
# deserialise from yaml
|
214
|
+
def init_with(coder)
|
215
|
+
@public_key = coder.scalar
|
216
|
+
# @public_key = coder['public_key']
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
class Cipher
|
221
|
+
# id_to_public_key_map is a Hash of id-name/(PublicKey | Array String ) pairs
|
222
|
+
def initialize(id_to_public_key_map)
|
223
|
+
@ids = id_to_public_key_map
|
224
|
+
end
|
225
|
+
|
226
|
+
# returns: PublicKey | nil | Array PublicKey
|
227
|
+
def dereference(audience_id_reference)
|
228
|
+
case ref = @ids[audience_id_reference]
|
229
|
+
when PublicKey
|
230
|
+
ref
|
231
|
+
when Array
|
232
|
+
ref.map {|audience_id_reference| dereference(audience_id_reference) }.flatten.compact.uniq
|
233
|
+
when Nil
|
234
|
+
puts "ID #{audience_id_reference} does not appear in the list of known public key identifiers"
|
235
|
+
nil
|
236
|
+
else
|
237
|
+
raise "ID reference #{audience_id_reference} corresponds to an unknown type of public key or transitive ID reference: #{ref.inspect}"
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
# returns: Array PublicKey
|
242
|
+
def dereference_all(audience_id_references)
|
243
|
+
audience_id_references.map {|audience_id_reference| dereference(audience_id_reference) }.flatten.compact.uniq
|
244
|
+
end
|
245
|
+
|
246
|
+
# value is the string value to be encrypted
|
247
|
+
# audience_id_references is an Array(String) representing the names associated with public keys in the ids section of the file
|
248
|
+
# returns the encrypted text as a String
|
249
|
+
def encrypt(value, audience_id_references)
|
250
|
+
raise "Not implemented"
|
251
|
+
end
|
252
|
+
|
253
|
+
# returns the decrypted text as a String
|
254
|
+
def decrypt(value)
|
255
|
+
raise "Not implemented"
|
256
|
+
end
|
257
|
+
|
258
|
+
def encrypted?(value)
|
259
|
+
raise "Not implemented"
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
class AgeEncryption
|
264
|
+
AGE_ENCRYPTED_FILE_HEADER = '-----BEGIN AGE ENCRYPTED FILE-----'
|
265
|
+
|
266
|
+
def self.encrypt(value, public_keys)
|
267
|
+
recipient_args = public_keys.map {|public_key| "-r #{public_key}" }
|
268
|
+
cmd = "age -e -a #{recipient_args.join(' ')}"
|
269
|
+
stdout, stderr, status = Open3.capture3(cmd, stdin_data: value)
|
270
|
+
raise "Failed to run age encryption: `#{cmd}`" unless status.success?
|
271
|
+
stdout
|
272
|
+
end
|
273
|
+
|
274
|
+
def self.decrypt(value, private_key_file_paths)
|
275
|
+
raise "Unable to decrypt the requested value because there is no age encryption identity (private key) specified" if private_key_file_paths.empty?
|
276
|
+
identity_file_args = private_key_file_paths.map {|private_key_file_path| "-i #{private_key_file_path}" }
|
277
|
+
cmd = "age -d #{identity_file_args.join(' ')}"
|
278
|
+
stdout, stderr, status = Open3.capture3(cmd, stdin_data: value)
|
279
|
+
raise "Failed to run age encryption: `#{cmd}`" unless status.success?
|
280
|
+
stdout
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
class AgeEncryptionCipher < Cipher
|
285
|
+
# id_to_public_key_map is a Hash of id-name/(PublicKey | Array String ) pairs
|
286
|
+
def initialize(id_to_public_key_map, private_key_file_paths)
|
287
|
+
super(id_to_public_key_map)
|
288
|
+
@private_key_file_paths = private_key_file_paths
|
289
|
+
end
|
290
|
+
|
291
|
+
# value is the string value to be encrypted
|
292
|
+
# audience_id_references is an Array(String) representing the names associated with public keys in the ids section of the file
|
293
|
+
# returns the encrypted text as a String
|
294
|
+
def encrypt(value, audience_id_references)
|
295
|
+
public_keys = dereference_all(audience_id_references)
|
296
|
+
AgeEncryption.encrypt(value, public_keys.map(&:key))
|
297
|
+
end
|
298
|
+
|
299
|
+
# returns the decrypted text as a String
|
300
|
+
def decrypt(value)
|
301
|
+
AgeEncryption.decrypt(value, @private_key_file_paths)
|
302
|
+
end
|
303
|
+
|
304
|
+
def encrypted?(value)
|
305
|
+
value.strip.start_with?(AgeEncryption::AGE_ENCRYPTED_FILE_HEADER)
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
|
310
|
+
class SecretRef
|
311
|
+
def initialize(secret_name)
|
312
|
+
@secret_name = secret_name
|
313
|
+
end
|
314
|
+
|
315
|
+
# #init_with and #encode_with are demonstrated here:
|
316
|
+
# - https://djellemah.com/blog/2014/07/23/yaml-deserialisation/
|
317
|
+
# - https://stackoverflow.com/questions/10629209/how-can-i-control-which-fields-to-serialize-with-yaml
|
318
|
+
# - https://github.com/protocolbuffers/protobuf/issues/4391
|
319
|
+
# serialise to yaml
|
320
|
+
def encode_with(coder)
|
321
|
+
# The following line seems to have the effect of quoting the @secret_name
|
322
|
+
# coder.style = Psych::Nodes::Mapping::FLOW
|
323
|
+
|
324
|
+
if @secret_name
|
325
|
+
# don't set tag explicitly, let Psych figure it out from yaml_tag
|
326
|
+
# coder.represent_scalar '!days', days.first
|
327
|
+
coder.scalar = @secret_name
|
328
|
+
# else
|
329
|
+
# # don't set tag explicitly, let Psych figure it out from yaml_tag
|
330
|
+
# # coder.represent_seq '!days', days.to_a
|
331
|
+
# coder.seq = @secret_name
|
332
|
+
end
|
333
|
+
end
|
334
|
+
|
335
|
+
# deserialise from yaml
|
336
|
+
def init_with(coder)
|
337
|
+
case coder.type
|
338
|
+
when :scalar
|
339
|
+
@secret_name = coder.scalar
|
340
|
+
# when :seq
|
341
|
+
# @secret_name = coder.seq
|
342
|
+
else
|
343
|
+
raise "Dunno how to handle #{coder.type} for #{coder.inspect}"
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
def to_s
|
348
|
+
@secret_name
|
349
|
+
end
|
350
|
+
end
|
351
|
+
# YAML.add_domain_type("", "secret") do |type, value|
|
352
|
+
# SecretRef.new(value)
|
353
|
+
# end
|
354
|
+
YAML.add_tag("!secret", SecretRef)
|
355
|
+
|
356
|
+
class Secret
|
357
|
+
def initialize(name, secret_value, id_references)
|
358
|
+
@name = name
|
359
|
+
@value = secret_value
|
360
|
+
@ids = id_references
|
361
|
+
end
|
362
|
+
|
363
|
+
# #init_with and #encode_with are demonstrated here:
|
364
|
+
# - https://djellemah.com/blog/2014/07/23/yaml-deserialisation/
|
365
|
+
# - https://stackoverflow.com/questions/10629209/how-can-i-control-which-fields-to-serialize-with-yaml
|
366
|
+
# - https://github.com/protocolbuffers/protobuf/issues/4391
|
367
|
+
# serialise to yaml
|
368
|
+
def encode_with(coder)
|
369
|
+
coder.tag = nil
|
370
|
+
|
371
|
+
# per https://rubydoc.info/stdlib/psych/3.0.0/Psych/Coder#scalar-instance_method
|
372
|
+
# coder.represent_scalar(nil, @public_key)
|
373
|
+
|
374
|
+
# coder.scalar = @public_key
|
375
|
+
# puts @ids.inspect
|
376
|
+
single_line_ids = @ids.join(", ")
|
377
|
+
if single_line_ids.size <= 80
|
378
|
+
coder['ids'] = single_line_ids
|
379
|
+
else
|
380
|
+
coder['ids'] = @ids
|
381
|
+
end
|
382
|
+
coder['value'] = @value
|
383
|
+
end
|
384
|
+
|
385
|
+
# deserialise from yaml
|
386
|
+
def init_with(coder)
|
387
|
+
@public_key = coder.scalar
|
388
|
+
# @public_key = coder['public_key']
|
389
|
+
end
|
390
|
+
|
391
|
+
def encrypt(cipher)
|
392
|
+
@value = cipher.encrypt(@value, @ids) unless cipher.encrypted?(@value)
|
393
|
+
@value
|
394
|
+
end
|
395
|
+
|
396
|
+
def decrypt(cipher)
|
397
|
+
@value = cipher.decrypt(@value) if cipher.encrypted?(@value)
|
398
|
+
@value
|
399
|
+
end
|
400
|
+
|
401
|
+
def to_s
|
402
|
+
@value
|
403
|
+
end
|
404
|
+
end
|
405
|
+
|
55
406
|
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require "pathname"
|
2
|
+
require "tty-editor"
|
3
|
+
require "yaml"
|
4
|
+
|
5
|
+
module OpsWalrus
|
6
|
+
class Inventory
|
7
|
+
# host_references is an array of host names and path strings that reference hosts.yaml files
|
8
|
+
# tags is an array of strings
|
9
|
+
def initialize(host_references = [HostsFile::DEFAULT_FILE_NAME], tags = [])
|
10
|
+
@host_references = host_references
|
11
|
+
@tags = tags
|
12
|
+
end
|
13
|
+
|
14
|
+
def hosts()
|
15
|
+
hosts_files, host_strings = @host_references.partition {|ref| File.exist?(ref) }
|
16
|
+
inventory_file_hosts = hosts_files.
|
17
|
+
map {|file_path| HostsFile.new(file_path) }.
|
18
|
+
reduce({}) do |host_map, hosts_file|
|
19
|
+
hosts_file.hosts.each do |host|
|
20
|
+
(host_map[host] ||= host).tag!(host.tags)
|
21
|
+
end
|
22
|
+
|
23
|
+
host_map
|
24
|
+
end.
|
25
|
+
keys
|
26
|
+
untagged_hosts = host_strings.map(&:strip).uniq.map {|host| Host.new(host) }
|
27
|
+
all_hosts = untagged_hosts + inventory_file_hosts
|
28
|
+
|
29
|
+
selected_hosts = if @tags.empty?
|
30
|
+
all_hosts
|
31
|
+
else
|
32
|
+
all_hosts.select do |host|
|
33
|
+
@tags.all? {|t| host.tags.include? t }
|
34
|
+
end
|
35
|
+
end.reject{|host| host.ignore? }
|
36
|
+
|
37
|
+
selected_hosts.sort_by(&:to_s)
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
end
|
data/lib/opswalrus/version.rb
CHANGED
data/vms/web-arch/Vagrantfile
CHANGED
@@ -74,13 +74,19 @@ Vagrant.configure("2") do |config|
|
|
74
74
|
# Enable provisioning with a shell script. Additional provisioners such as
|
75
75
|
# Ansible, Chef, Docker, Puppet and Salt are also available. Please see the
|
76
76
|
# documentation for more information about their specific syntax and use.
|
77
|
-
config.vm.provision "shell"
|
78
|
-
|
79
|
-
|
77
|
+
config.vm.provision "shell" do |s|
|
78
|
+
ssh_pub_key = File.readlines("#{Dir.home}/.ssh/id_ops.pub").first.strip
|
79
|
+
s.inline = <<-SHELL
|
80
|
+
echo #{ssh_pub_key} >> /home/vagrant/.ssh/authorized_keys
|
81
|
+
echo #{ssh_pub_key} >> /root/.ssh/authorized_keys
|
80
82
|
|
81
|
-
|
82
|
-
|
83
|
+
sudo sed -i 's/PasswordAuthentication no/PasswordAuthentication yes/g' /etc/ssh/sshd_config
|
84
|
+
sudo systemctl restart sshd
|
83
85
|
|
84
|
-
|
85
|
-
|
86
|
+
# change vagrant user to require sudo password
|
87
|
+
sudo sed -i 's/vagrant ALL=(ALL) NOPASSWD: ALL/vagrant ALL=(ALL:ALL) ALL/g' /etc/sudoers.d/vagrant
|
88
|
+
|
89
|
+
# sudo sh -c 'echo root:foo | chpasswd'
|
90
|
+
SHELL
|
91
|
+
end
|
86
92
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: opswalrus
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.
|
4
|
+
version: 1.0.33
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- David Ellis
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-09-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: amazing_print
|
@@ -194,6 +194,7 @@ files:
|
|
194
194
|
- lib/opswalrus/host.rb
|
195
195
|
- lib/opswalrus/hosts_file.rb
|
196
196
|
- lib/opswalrus/interaction_handlers.rb
|
197
|
+
- lib/opswalrus/inventory.rb
|
197
198
|
- lib/opswalrus/invocation.rb
|
198
199
|
- lib/opswalrus/local_non_blocking_backend.rb
|
199
200
|
- lib/opswalrus/local_pty_backend.rb
|