opswalrus 1.0.30 → 1.0.32

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 312fb5f2541ced41eeec5b3b7eb1cc6774d0884dfc02487675dfe974d10504fa
4
- data.tar.gz: 40658c9a4515ab5d5d5652e301991163136a339399c21f2acc37ea3546716e80
3
+ metadata.gz: c48630a4544c676d6a2dd18c77799907e19c413fcc0fb5e8d4e5437990877c71
4
+ data.tar.gz: 39f117ef87f69593b58dfccede64b3a0da0b034abc56e78f59a386362ab052c9
5
5
  SHA512:
6
- metadata.gz: a24a2a3dc3e0287c0ed1cae15284dc45943dd209c18a5c9698cd8632c3a07d5da68e3abd4af468ff79511f42eb40fd17a72e1508ebe9358a492b288a51b0faa3
7
- data.tar.gz: b240ef837c47d9b942c3c61c88e3ffbf73db88016374c037e012e8d5c901a1e6d07774591a9d91b14080232c04dc6e4c210cf9334bb8d6adc3988de637c48573
6
+ metadata.gz: d0966d03fafbacaf5034bc13b785a49cff7a887ee5a2059c29c9db8f2df8a0a9a2e9cfbba2eeae0360088b89dee67a3a1cc7331ae9dedc8e3ed00fc52b818241
7
+ data.tar.gz: c8fed3b4eb606f7be0703187d9a0f2297ea96c2b937bfba8b30ca5a4f5bd43a5e921aa0b9ef187dc7a8c52bcd86484e65393e6b49e6d3667ab103f9b2e3b352a
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- opswalrus (1.0.30)
4
+ opswalrus (1.0.32)
5
5
  amazing_print (~> 1.5)
6
6
  bcrypt_pbkdf (~> 1.1)
7
7
  citrus (~> 3.0)
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 0, "Bitwarden is not unlocked. Please unlock bitwarden with: bw unlock"
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
- require_relative "errors"
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 = ["hosts.yaml"] if (host_references.nil? || host_references.empty?) && File.exist?("hosts.yaml")
392
+ host_references = [HostsFile::DEFAULT_FILE_NAME] if (host_references.nil? || host_references.empty?) && File.exist?(HostsFile::DEFAULT_FILE_NAME)
385
393
 
386
- hosts_files, host_strings = host_references.partition {|ref| File.exist?(ref) }
387
- hosts_files = hosts_files.map {|file_path| HostsFile.new(file_path) }
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
- host_map
395
- end.keys
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
- selected_hosts = if tags.empty?
399
- all_hosts
400
- else
401
- all_hosts.select do |host|
402
- tags.all? {|t| host.tags.include? t }
403
- end
404
- end
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
- selected_hosts.sort_by(&:to_s)
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/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 'Report on the host inventory'
49
- long_desc 'Report on the host inventory'
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
- log_level = global_options[:debug] && :trace || global_options[:verbose] && :debug || :info
56
- $app.set_log_level(log_level)
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
- $app.report_inventory(hosts, tags: tags)
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
 
@@ -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"
@@ -192,7 +193,7 @@ module OpsWalrus
192
193
 
193
194
  # cmd = "OPSWALRUS_LOCAL_HOSTNAME='#{local_hostname_for_remote_host}'; /home/linuxbrew/.linuxbrew/bin/gem exec --conservative -g opswalrus ops"
194
195
  # cmd = "OPS_GEM=\"#{OPS_GEM}\" OPSWALRUS_LOCAL_HOSTNAME='#{local_hostname_for_remote_host}'; $OPS_GEM exec --conservative -g opswalrus ops"
195
- cmd = "OPSWALRUS_LOCAL_HOSTNAME='#{local_hostname_for_remote_host}'; #{OPS_CMD}"
196
+ cmd = "OPSWALRUS_LOCAL_HOSTNAME='#{local_hostname_for_remote_host}' eval #{OPS_CMD}"
196
197
  cmd << " -v" if verbose
197
198
  cmd << " #{ops_command.to_s}"
198
199
  cmd << " #{ops_command_options.to_s}" if ops_command_options
@@ -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 ssh_keys
233
- @props["keys"]
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: ssh_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
@@ -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 = YAML.load(File.read(hosts_file_path)) if File.exist?(hosts_file_path)
13
- # puts @yaml.inspect
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 host_ref == "default" || host_ref == "defaults" # this maps to a nil
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.merge(host_params))
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
@@ -1,3 +1,3 @@
1
1
  module OpsWalrus
2
- VERSION = "1.0.30"
2
+ VERSION = "1.0.32"
3
3
  end
@@ -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", inline: <<-SHELL
78
- sudo sed -i 's/PasswordAuthentication no/PasswordAuthentication yes/g' /etc/ssh/sshd_config
79
- sudo systemctl restart sshd
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
- # change vagrant user to require sudo password
82
- sudo sed -i 's/vagrant ALL=(ALL) NOPASSWD: ALL/vagrant ALL=(ALL:ALL) ALL/g' /etc/sudoers.d/vagrant
83
+ sudo sed -i 's/PasswordAuthentication no/PasswordAuthentication yes/g' /etc/ssh/sshd_config
84
+ sudo systemctl restart sshd
83
85
 
84
- # sudo sh -c 'echo root:foo | chpasswd'
85
- SHELL
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.30
4
+ version: 1.0.32
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-08-26 00:00:00.000000000 Z
11
+ date: 2023-09-02 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