paramsync 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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