paramsync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ # This software is public domain. No rights are reserved. See LICENSE for more information.
2
+
3
+ class Paramsync
4
+ class CLI
5
+ class CheckCommand
6
+ class << self
7
+ def run(args)
8
+ Paramsync::CLI.configure
9
+
10
+ mode = if args.include?("--pull")
11
+ :pull
12
+ else
13
+ :push
14
+ end
15
+
16
+ Paramsync.config.sync_targets.each do |target|
17
+ diff = target.diff(mode)
18
+ diff.print_report
19
+ if not diff.any_changes?
20
+ puts "No changes to make for this sync target."
21
+ end
22
+ puts
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,45 @@
1
+ # This software is public domain. No rights are reserved. See LICENSE for more information.
2
+
3
+ class Paramsync
4
+ class CLI
5
+ class ConfigCommand
6
+ class << self
7
+ def run
8
+ Paramsync::CLI.configure
9
+
10
+ puts " Config file: #{Paramsync.config.config_file}"
11
+ puts " Verbose: #{Paramsync.config.verbose?.to_s.bold}"
12
+ puts
13
+ puts "Sync target defaults:"
14
+ puts " Chomp trailing newlines from local files: #{Paramsync.config.chomp?.to_s.bold}"
15
+ puts " Delete remote keys with no local file: #{Paramsync.config.delete?.to_s.bold}"
16
+ puts
17
+ puts "Sync targets:"
18
+
19
+ Paramsync.config.sync_targets.each do |target|
20
+ if target.name
21
+ puts "* #{target.name.bold}"
22
+ print ' '
23
+ else
24
+ print '*'
25
+ end
26
+ puts " Region: #{target.region}"
27
+ puts " Local type: #{target.type == :dir ? 'Directory' : 'Single file'}"
28
+ puts " #{target.type == :dir ? " Dir" : "File"} path: #{target.path}"
29
+ puts " Prefix: #{target.prefix}"
30
+ puts " Account: #{target.account}"
31
+ puts " Autochomp? #{target.chomp?}"
32
+ puts " Delete? #{target.delete?}"
33
+ if not target.exclude.empty?
34
+ puts " Exclusions:"
35
+ target.exclude.each do |exclusion|
36
+ puts " - #{exclusion}"
37
+ end
38
+ end
39
+ puts
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ # This software is public domain. No rights are reserved. See LICENSE for more information.
2
+
3
+ class Paramsync
4
+ class CLI
5
+ class EncryptCommand
6
+ class << self
7
+ def run(args)
8
+ Paramsync::CLI.configure
9
+ STDOUT.sync = true
10
+
11
+ ciphertext = Paramsync.config.kms_client.encrypt(
12
+ key_id: Paramsync.config.kms_key,
13
+ plaintext: args[1]
14
+ ).ciphertext_blob
15
+
16
+ hash = { args[0] => ciphertext }
17
+ puts YAML.dump(hash).gsub("---\n", '')
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,141 @@
1
+ # This software is public domain. No rights are reserved. See LICENSE for more information.
2
+
3
+ class Paramsync
4
+ class CLI
5
+ class PullCommand
6
+ class << self
7
+ def run(args)
8
+ Paramsync::CLI.configure
9
+ STDOUT.sync = true
10
+
11
+ Paramsync.config.sync_targets.each do |target|
12
+ diff = target.diff(:pull)
13
+
14
+ diff.print_report
15
+
16
+ if not diff.any_changes?
17
+ puts
18
+ puts "Everything is in sync. No changes need to be made to this sync target."
19
+ next
20
+ end
21
+
22
+ puts
23
+ puts "Do you want to pull these changes?"
24
+ print " Enter '" + "yes".bold + "' to continue: "
25
+ answer = args.include?('--yes') ? 'yes' : gets.chomp
26
+
27
+ if answer.downcase != "yes"
28
+ puts
29
+ puts "Pull cancelled. No changes will be made to this sync target."
30
+ next
31
+ end
32
+
33
+ puts
34
+ case target.type
35
+ when :dir then self.pull_dir(diff)
36
+ when :file then self.pull_file(diff)
37
+ end
38
+ end
39
+ end
40
+
41
+ def pull_dir(diff)
42
+ diff.items_to_change.each do |item|
43
+ case item.op
44
+ when :create
45
+ print "CREATE".bold.green + " " + item.display_filename
46
+ begin
47
+ FileUtils.mkdir_p(File.dirname(item.filename))
48
+ # attempt to write atomically-ish
49
+ tmpfile = item.filename + ".paramsync-tmp"
50
+ File.open(tmpfile, "w") do |f|
51
+ f.write(writable_content(item.remote_content))
52
+ end
53
+ FileUtils.move(tmpfile, item.filename)
54
+ puts " OK".bold
55
+ rescue => e
56
+ puts " ERROR".bold.red
57
+ puts " #{e}"
58
+ end
59
+
60
+ when :update
61
+ print "UPDATE".bold.blue + " " + item.display_filename
62
+ begin
63
+ # attempt to write atomically-ish
64
+ tmpfile = item.filename + ".paramsync-tmp"
65
+ File.open(tmpfile, "w") do |f|
66
+ f.write(writable_content(item.remote_content))
67
+ end
68
+ FileUtils.move(tmpfile, item.filename)
69
+ puts " OK".bold
70
+ rescue => e
71
+ puts " ERROR".bold.red
72
+ puts " #{e}"
73
+ end
74
+
75
+ when :delete
76
+ print "DELETE".bold.red + " " + item.display_filename
77
+ begin
78
+ File.unlink(item.filename)
79
+ puts " OK".bold
80
+ rescue => e
81
+ puts " ERROR".bold.red
82
+ puts " #{e}"
83
+ end
84
+
85
+ else
86
+ if Paramsync.config.verbose?
87
+ STDERR.puts "paramsync: WARNING: unexpected operation '#{item.op}' for #{item.display_filename}"
88
+ next
89
+ end
90
+
91
+ end
92
+ end
93
+ end
94
+
95
+ def pull_file(diff)
96
+ # build and write the file
97
+ filename_list = diff.items_to_change.collect(&:filename).uniq
98
+ if filename_list.length != 1
99
+ raise Paramsync::InternalError.new("Multiple filenames found for a 'file' type sync target. Something has gone wrong.")
100
+ end
101
+ filename = filename_list.first
102
+ display_filename = filename.trim_path
103
+
104
+ if File.exist?(filename)
105
+ print "UPDATE".bold.blue + " " + display_filename
106
+ else
107
+ print "CREATE".bold.green + " " + display_filename
108
+ end
109
+
110
+ begin
111
+ FileUtils.mkdir_p(File.dirname(filename))
112
+ # attempt to write atomically-ish
113
+ tmpfile = filename + ".paramsync-tmp"
114
+ File.open(tmpfile, "w") do |f|
115
+ f.write(diff.final_items.transform_values { |v| writable_content(v) }.to_yaml)
116
+ end
117
+ FileUtils.move(tmpfile, filename)
118
+ puts " OK".bold
119
+ rescue => e
120
+ puts " ERROR".bold.red
121
+ puts " #{e}"
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ def writable_content(remote_content)
128
+ to_write = remote_content[0]
129
+ if(remote_content[1])
130
+ to_write = Paramsync.config.kms_client.encrypt(
131
+ key_id: Paramsync.config.kms_key,
132
+ plaintext: to_write
133
+ ).ciphertext_blob
134
+ end
135
+ to_write
136
+ end
137
+
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,85 @@
1
+ # This software is public domain. No rights are reserved. See LICENSE for more information.
2
+
3
+ class Paramsync
4
+ class CLI
5
+ class PushCommand
6
+ class << self
7
+ def run(args)
8
+ Paramsync::CLI.configure
9
+ STDOUT.sync = true
10
+
11
+ Paramsync.config.sync_targets.each do |target|
12
+ diff = target.diff(:push)
13
+
14
+ diff.print_report
15
+
16
+ if not diff.any_changes?
17
+ puts
18
+ puts "Everything is in sync. No changes need to be made to this sync target."
19
+ next
20
+ end
21
+
22
+ puts
23
+ puts "Do you want to push these changes?"
24
+ print " Enter '" + "yes".bold + "' to continue: "
25
+ answer = args.include?('--yes') ? 'yes' : gets.chomp
26
+
27
+ if answer.downcase != "yes"
28
+ puts
29
+ puts "Push cancelled. No changes will be made to this sync target."
30
+ next
31
+ end
32
+
33
+ puts
34
+ diff.items_to_change.each do |item|
35
+ case item.op
36
+ when :create
37
+ print "CREATE".bold.green + " " + item.ssm_key
38
+ begin
39
+ target.ssm.put_parameter(
40
+ name: item.ssm_key,
41
+ value: item.local_content[0],
42
+ type: item.local_content[1] ? 'SecureString' : 'String'
43
+ )
44
+ puts " OK".bold
45
+ rescue
46
+ puts " ERROR".bold.red
47
+ end
48
+
49
+ when :update
50
+ print "UPDATE".bold.blue + " " + item.ssm_key
51
+ begin
52
+ target.ssm.put_parameter(
53
+ name: item.ssm_key,
54
+ value: item.local_content[0],
55
+ type: item.local_content[1] ? 'SecureString' : 'String',
56
+ overwrite: true
57
+ )
58
+ puts " OK".bold
59
+ rescue
60
+ puts " ERROR".bold.red
61
+ end
62
+
63
+ when :delete
64
+ print "DELETE".bold.red + " " + item.ssm_key
65
+ begin
66
+ target.ssm.delete_parameter(name: item.ssm_key)
67
+ puts " OK".bold
68
+ rescue
69
+ puts " ERROR".bold.red
70
+ end
71
+
72
+ else
73
+ if Paramsync.config.verbose?
74
+ STDERR.puts "paramsync: WARNING: unexpected operation '#{item.op}' for #{item.ssm_key}"
75
+ next
76
+ end
77
+
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,21 @@
1
+ # This software is public domain. No rights are reserved. See LICENSE for more information.
2
+
3
+ class Paramsync
4
+ class CLI
5
+ class TargetsCommand
6
+ class << self
7
+ def run
8
+ Paramsync::CLI.configure
9
+
10
+ Paramsync.config.sync_targets.each do |target|
11
+ if target.name
12
+ puts target.name
13
+ else
14
+ puts "[unnamed target] #{target.region}:#{target.prefix}"
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,172 @@
1
+ # This software is public domain. No rights are reserved. See LICENSE for more information.
2
+
3
+ require 'ostruct'
4
+
5
+ class Paramsync
6
+ class ConfigFileNotFound < RuntimeError; end
7
+ class ConfigFileInvalid < RuntimeError; end
8
+
9
+ class Config
10
+ CONFIG_FILENAMES = %w( paramsync.yml )
11
+ VALID_CONFIG_KEYS = %w( sync ssm paramsync )
12
+ VALID_SSM_CONFIG_KEYS = %w( accounts kms )
13
+ VALID_PARAMSYNC_CONFIG_KEYS = %w( verbose chomp delete color )
14
+
15
+ attr_accessor :config_file, :base_dir, :sync_targets, :target_allowlist, :ssm_accounts, :kms_client, :kms_key
16
+
17
+ class << self
18
+ # discover the nearest config file
19
+ def discover(dir: nil)
20
+ dir ||= Dir.pwd
21
+
22
+ CONFIG_FILENAMES.each do |filename|
23
+ full_path = File.join(dir, filename)
24
+ if File.exist?(full_path)
25
+ return full_path
26
+ end
27
+ end
28
+
29
+ dir == "/" ? nil : self.discover(dir: File.dirname(dir))
30
+ end
31
+ end
32
+
33
+ def initialize(path: nil, targets: nil)
34
+ if path.nil? or File.directory?(path)
35
+ self.config_file = Paramsync::Config.discover(dir: path)
36
+ elsif File.exist?(path)
37
+ self.config_file = path
38
+ else
39
+ raise Paramsync::ConfigFileNotFound.new
40
+ end
41
+
42
+ if self.config_file.nil? or not File.exist?(self.config_file) or not File.readable?(self.config_file)
43
+ raise Paramsync::ConfigFileNotFound.new
44
+ end
45
+
46
+ self.config_file = File.expand_path(self.config_file)
47
+ self.base_dir = File.dirname(self.config_file)
48
+ self.target_allowlist = targets
49
+ parse!
50
+ end
51
+
52
+ def verbose?
53
+ @is_verbose
54
+ end
55
+
56
+ def chomp?
57
+ @do_chomp
58
+ end
59
+
60
+ def delete?
61
+ @do_delete
62
+ end
63
+
64
+ def color?
65
+ @use_color
66
+ end
67
+
68
+ def parse!
69
+ raw = {}
70
+ begin
71
+ raw = YAML.load(ERB.new(File.read(self.config_file)).result)
72
+ rescue
73
+ raise Paramsync::ConfigFileInvalid.new("Unable to parse config file as YAML")
74
+ end
75
+
76
+ if raw.is_a? FalseClass
77
+ # this generally means an empty config file
78
+ raw = {}
79
+ end
80
+
81
+ if not raw.is_a? Hash
82
+ raise Paramsync::ConfigFileInvalid.new("Config file must form a hash")
83
+ end
84
+
85
+ raw['ssm'] ||= {}
86
+ if not raw['ssm'].is_a? Hash
87
+ raise Paramsync::ConfigFileInvalid.new("'ssm' must be a hash")
88
+ end
89
+
90
+ if (raw['ssm'].keys - VALID_SSM_CONFIG_KEYS) != []
91
+ raise Paramsync::ConfigFileInvalid.new("Only the following keys are valid in the ssm config: #{VALID_SSM_CONFIG_KEYS.join(", ")}")
92
+ end
93
+
94
+ self.ssm_accounts = raw['ssm']['accounts']
95
+
96
+ raw['paramsync'] ||= {}
97
+ if not raw['paramsync'].is_a? Hash
98
+ raise Paramsync::ConfigFileInvalid.new("'paramsync' must be a hash")
99
+ end
100
+
101
+ if (raw['paramsync'].keys - VALID_PARAMSYNC_CONFIG_KEYS) != []
102
+ raise Paramsync::ConfigFileInvalid.new("Only the following keys are valid in the 'paramsync' config block: #{VALID_PARAMSYNC_CONFIG_KEYS.join(", ")}")
103
+ end
104
+
105
+ # verbose: default false
106
+ @is_verbose = raw['paramsync']['verbose'] ? true : false
107
+ if ENV['PARAMSYNC_VERBOSE']
108
+ @is_verbose = true
109
+ end
110
+
111
+ # chomp: default true
112
+ if raw['paramsync'].has_key?('chomp')
113
+ @do_chomp = raw['paramsync']['chomp'] ? true : false
114
+ else
115
+ @do_chomp = true
116
+ end
117
+
118
+ # delete: default false
119
+ @do_delete = raw['paramsync']['delete'] ? true : false
120
+
121
+ raw['sync'] ||= []
122
+ if not raw['sync'].is_a? Array
123
+ raise Paramsync::ConfigFileInvalid.new("'sync' must be an array")
124
+ end
125
+
126
+ # color: default true
127
+ if raw['paramsync'].has_key?('color')
128
+ @use_color = raw['paramsync']['color'] ? true : false
129
+ else
130
+ @use_color = true
131
+ end
132
+
133
+ if raw['ssm'].has_key?('kms')
134
+ self.kms_client = Aws::KMS::Client.new(
135
+ region: raw['ssm']['kms']['region'],
136
+ credentials: Aws::AssumeRoleCredentials.new(
137
+ client: Aws::STS::Client.new(region: raw['ssm']['kms']['region']),
138
+ role_arn: raw['ssm']['kms']['role'],
139
+ role_session_name: "paramsync"
140
+ ),
141
+ )
142
+ self.kms_key = raw['ssm']['kms']['arn']
143
+ end
144
+
145
+ self.sync_targets = []
146
+ raw['sync'].each do |target|
147
+ if target.is_a? Hash
148
+ if target['chomp'].nil?
149
+ target['chomp'] = self.chomp?
150
+ end
151
+ if target['delete'].nil?
152
+ target['delete'] = self.delete?
153
+ end
154
+ account = self.ssm_accounts[target['account']]
155
+ if account.nil?
156
+ raise Paramsync::ConfigFileInvalid.new("Account '#{target['account']}' is not defined")
157
+ end
158
+ end
159
+
160
+ if not self.target_allowlist.nil?
161
+ # unnamed targets cannot be allowlisted
162
+ next if target['name'].nil?
163
+
164
+ # named targets must be on the allowlist
165
+ next if not self.target_allowlist.include?(target['name'])
166
+ end
167
+
168
+ self.sync_targets << Paramsync::SyncTarget.new(config: target, account: account['role'], base_dir: self.base_dir)
169
+ end
170
+ end
171
+ end
172
+ end