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.
- checksums.yaml +7 -0
- data/LICENSE +12 -0
- data/README.md +334 -0
- data/bin/paramsync +8 -0
- data/lib/paramsync.rb +73 -0
- data/lib/paramsync/cli.rb +148 -0
- data/lib/paramsync/cli/check_command.rb +28 -0
- data/lib/paramsync/cli/config_command.rb +45 -0
- data/lib/paramsync/cli/encrypt_command.rb +22 -0
- data/lib/paramsync/cli/pull_command.rb +141 -0
- data/lib/paramsync/cli/push_command.rb +85 -0
- data/lib/paramsync/cli/targets_command.rb +21 -0
- data/lib/paramsync/config.rb +172 -0
- data/lib/paramsync/diff.rb +217 -0
- data/lib/paramsync/sync_target.rb +191 -0
- data/lib/paramsync/version.rb +5 -0
- data/paramsync.gemspec +31 -0
- metadata +103 -0
@@ -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
|