paramsync 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,217 @@
1
+ # This software is public domain. No rights are reserved. See LICENSE for more information.
2
+
3
+ class Paramsync
4
+ class Diff
5
+ def initialize(target:, local:, remote:, mode:)
6
+ @target = target
7
+ @local = local
8
+ @remote = remote
9
+ @mode = mode
10
+
11
+ @all_keys = (@local.keys + @remote.keys).sort.uniq
12
+
13
+ @diff =
14
+ @all_keys.collect do |key|
15
+ excluded = false
16
+ op = :noop
17
+ if @remote.has_key?(key) and not @local.has_key?(key)
18
+ case @mode
19
+ when :push
20
+ op = @target.delete? ? :delete : :ignore
21
+ when :pull
22
+ op = :create
23
+ end
24
+ elsif @local.has_key?(key) and not @remote.has_key?(key)
25
+ case @mode
26
+ when :push
27
+ op = :create
28
+ when :pull
29
+ op = @target.delete? ? :delete : :ignore
30
+ end
31
+ else
32
+ if @remote[key] == @local[key]
33
+ op = :noop
34
+ else
35
+ op = :update
36
+ end
37
+ end
38
+
39
+ ssm_key = [@target.prefix, key].compact.join("/").gsub('/.', '/').squeeze("/")
40
+
41
+ if @target.exclude.include?(key) or @target.exclude.include?(ssm_key)
42
+ op = :ignore
43
+ excluded = true
44
+ end
45
+
46
+ filename =
47
+ case @target.type
48
+ when :dir then File.join(@target.base_path, key)
49
+ when :file then @target.base_path
50
+ end
51
+
52
+ display_filename =
53
+ case @target.type
54
+ when :dir then File.join(@target.base_path, key).trim_path
55
+ when :file then "#{@target.base_path.trim_path}#{':'.gray}#{key.cyan}"
56
+ end
57
+
58
+ OpenStruct.new(
59
+ op: op,
60
+ excluded: excluded,
61
+ relative_path: key,
62
+ filename: filename,
63
+ display_filename: display_filename,
64
+ ssm_key: ssm_key,
65
+ local_content: @local[key],
66
+ remote_content: @remote[key],
67
+ )
68
+ end
69
+ end
70
+
71
+ def items_to_delete
72
+ @diff.select { |d| d.op == :delete }
73
+ end
74
+
75
+ def items_to_update
76
+ @diff.select { |d| d.op == :update }
77
+ end
78
+
79
+ def items_to_create
80
+ @diff.select { |d| d.op == :create }
81
+ end
82
+
83
+ def items_to_ignore
84
+ @diff.select { |d| d.op == :ignore }
85
+ end
86
+
87
+ def items_to_exclude
88
+ @diff.select { |d| d.op == :ignore and d.excluded == true }
89
+ end
90
+
91
+ def items_to_noop
92
+ @diff.select { |d| d.op == :noop }
93
+ end
94
+
95
+ def items_to_change
96
+ @diff.select { |d| [:delete, :update, :create].include?(d.op) }
97
+ end
98
+
99
+ def final_items
100
+ case @mode
101
+ when :push then @local
102
+ when :pull then @remote
103
+ end
104
+ end
105
+
106
+ def any_changes?
107
+ self.items_to_change.count > 0
108
+ end
109
+
110
+ def print_report
111
+ puts '='*85
112
+ puts @target.description(@mode)
113
+
114
+ puts " Keys scanned: #{@diff.count}"
115
+ if Paramsync.config.verbose?
116
+ puts " Keys ignored: #{self.items_to_ignore.count}"
117
+ puts " Keys in sync: #{self.items_to_noop.count}"
118
+ end
119
+
120
+ puts if self.any_changes?
121
+
122
+ from_content_key, to_content_key, to_path_key, to_type_display_name =
123
+ case @mode
124
+ when :push then [:local_content, :remote_content, :ssm_key, "Keys"]
125
+ when :pull
126
+ case @target.type
127
+ when :dir then [:remote_content, :local_content, :display_filename, "Files"]
128
+ when :file then [:remote_content, :local_content, :display_filename, "File entries"]
129
+ end
130
+ end
131
+
132
+ @diff.each do |item|
133
+ case item.op
134
+ when :create
135
+ puts "CREATE".bold.green + " #{item[to_path_key]}"
136
+ if(item[from_content_key][1])
137
+ puts '[SECURE]'
138
+ end
139
+ puts '-'*85
140
+ # simulate diff but without complaints about line endings
141
+ item[from_content_key][0].each_line do |line|
142
+ puts "+#{line.chomp}".green
143
+ end
144
+ puts '-'*85
145
+
146
+ when :update
147
+ puts "UPDATE".bold + " #{item[to_path_key]}"
148
+ if item[to_content_key][1] != item[from_content_key][1]
149
+ puts "#{item[to_content_key][1] ? '[SECURE]' : 'insecure'} => #{item[from_content_key][1] ? '[SECURE]' : 'insecure'}"
150
+ end
151
+ puts '-'*85
152
+ if item[to_content_key][0] != item[from_content_key][0]
153
+ puts Diffy::Diff.new(item[to_content_key][0], item[from_content_key][0]).to_s(:color)
154
+ end
155
+ puts '-'*85
156
+
157
+ when :delete
158
+ if @target.delete?
159
+ puts "DELETE".bold.red + " #{item[to_path_key]}"
160
+ puts '-'*85
161
+ # simulate diff but without complaints about line endings
162
+ item[to_content_key][0].each_line do |line|
163
+ puts "-#{line.chomp}".red
164
+ end
165
+ puts '-'*85
166
+ else
167
+ if Paramsync.config.verbose?
168
+ puts "IGNORE".bold + " #{item[to_path_key]}"
169
+ end
170
+ end
171
+
172
+ when :ignore
173
+ if Paramsync.config.verbose?
174
+ puts "IGNORE".bold + " #{item[to_path_key]}"
175
+ end
176
+
177
+ when :noop
178
+ if Paramsync.config.verbose?
179
+ puts "NO-OP!".bold + " #{item[to_path_key]}"
180
+ end
181
+
182
+ else
183
+ if Paramsync.config.verbose?
184
+ STDERR.puts "WARNING: unexpected operation '#{item.op}' for #{item[to_path_key]}"
185
+ end
186
+
187
+ end
188
+ end
189
+
190
+ if self.items_to_create.count > 0
191
+ puts
192
+ puts "#{to_type_display_name} to create: #{self.items_to_create.count}".bold
193
+ self.items_to_create.each do |item|
194
+ puts "+ #{item[to_path_key]}".green
195
+ end
196
+ end
197
+
198
+ if self.items_to_update.count > 0
199
+ puts
200
+ puts "#{to_type_display_name} to update: #{self.items_to_update.count}".bold
201
+ self.items_to_update.each do |item|
202
+ puts "~ #{item[to_path_key]}".blue
203
+ end
204
+ end
205
+
206
+ if @target.delete?
207
+ if self.items_to_delete.count > 0
208
+ puts
209
+ puts "#{to_type_display_name} to delete: #{self.items_to_delete.count}".bold
210
+ self.items_to_delete.each do |item|
211
+ puts "- #{item[to_path_key]}".red
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,191 @@
1
+ # This software is public domain. No rights are reserved. See LICENSE for more information.
2
+
3
+ class Paramsync
4
+ class SyncTarget
5
+ VALID_CONFIG_KEYS = %w( name type region prefix path exclude chomp delete erb_enabled account )
6
+ attr_accessor :name, :type, :region, :prefix, :path, :exclude, :erb_enabled, :account, :ssm
7
+
8
+ REQUIRED_CONFIG_KEYS = %w( prefix region account )
9
+ VALID_TYPES = [ :dir, :file ]
10
+ DEFAULT_TYPE = :dir
11
+
12
+ def initialize(config:, account:, base_dir:)
13
+ if not config.is_a? Hash
14
+ raise Paramsync::ConfigFileInvalid.new("Sync target entries must be specified as hashes")
15
+ end
16
+
17
+ if (config.keys - Paramsync::SyncTarget::VALID_CONFIG_KEYS) != []
18
+ raise Paramsync::ConfigFileInvalid.new("Only the following keys are valid in a sync target entry: #{Paramsync::SyncTarget::VALID_CONFIG_KEYS.join(", ")}")
19
+ end
20
+
21
+ if (Paramsync::SyncTarget::REQUIRED_CONFIG_KEYS - config.keys) != []
22
+ raise Paramsync::ConfigFileInvalid.new("The following keys are required in a sync target entry: #{Paramsync::SyncTarget::REQUIRED_CONFIG_KEYS.join(", ")}")
23
+ end
24
+
25
+ @base_dir = base_dir
26
+ self.region = config['region']
27
+ self.prefix = config['prefix']
28
+ self.account = config['account']
29
+ self.path = config['path'] || config['prefix']
30
+ self.name = config['name']
31
+ self.type = (config['type'] || Paramsync::SyncTarget::DEFAULT_TYPE).to_sym
32
+ unless Paramsync::SyncTarget::VALID_TYPES.include?(self.type)
33
+ raise Paramsync::ConfigFileInvalid.new("Sync target '#{self.name || self.path}' has type '#{self.type}'. But only the following types are valid: #{Paramsync::SyncTarget::VALID_TYPES.collect(&:to_s).join(", ")}")
34
+ end
35
+
36
+ if self.type == :file and File.directory?(self.base_path)
37
+ raise Paramsync::ConfigFileInvalid.new("Sync target '#{self.name || self.path}' has type 'file', but path '#{self.path}' is a directory.")
38
+ end
39
+
40
+ self.exclude = config['exclude'] || []
41
+ if config.has_key?('chomp')
42
+ @do_chomp = config['chomp'] ? true : false
43
+ end
44
+ if config.has_key?('delete')
45
+ @do_delete = config['delete'] ? true : false
46
+ else
47
+ @do_delete = false
48
+ end
49
+
50
+ self.ssm = Aws::SSM::Client.new(
51
+ region: region,
52
+ credentials: Aws::AssumeRoleCredentials.new(
53
+ client: Aws::STS::Client.new(region: region),
54
+ role_arn: account,
55
+ role_session_name: "paramsync"
56
+ ),
57
+ )
58
+ self.erb_enabled = config['erb_enabled']
59
+ end
60
+
61
+ def erb_enabled?
62
+ @erb_enabled
63
+ end
64
+
65
+ def chomp?
66
+ @do_chomp
67
+ end
68
+
69
+ def delete?
70
+ @do_delete
71
+ end
72
+
73
+ def description(mode = :push)
74
+ if mode == :pull
75
+ "#{self.name.nil? ? '' : self.name.bold + "\n"}#{'ssm'.cyan}:#{self.region.green}:#{self.prefix} => #{'local'.blue}:#{self.path}"
76
+ else
77
+ "#{self.name.nil? ? '' : self.name.bold + "\n"}#{'local'.blue}:#{self.path} => #{'ssm'.cyan}:#{self.region.green}:#{self.prefix}"
78
+ end
79
+ end
80
+
81
+ def clear_cache
82
+ @base_path = nil
83
+ @local_files = nil
84
+ @local_items = nil
85
+ @remote_items = nil
86
+ end
87
+
88
+ def base_path
89
+ @base_path ||= File.join(@base_dir, self.path)
90
+ end
91
+
92
+ def local_files
93
+ # see https://stackoverflow.com/questions/357754/can-i-traverse-symlinked-directories-in-ruby-with-a-glob
94
+ @local_files ||= Dir["#{self.base_path}/**{,/*/**}/*"].select { |f| File.file?(f) }
95
+ end
96
+
97
+ def local_items
98
+ return @local_items if not @local_items.nil?
99
+ @local_items = {}
100
+
101
+ case self.type
102
+ when :dir
103
+ self.local_files.each do |local_file|
104
+ @local_items[local_file.sub(%r{^#{self.base_path}/?}, '')] =
105
+ load_local_file(local_file)
106
+ end
107
+
108
+ when :file
109
+ if File.exist?(self.base_path)
110
+ @local_items = local_items_from_file
111
+ end
112
+ end
113
+ @local_items.transform_values! do |val|
114
+ is_kms = val.bytes[0] == 0x01
115
+ if is_kms
116
+ val = Paramsync.config.kms_client.decrypt(
117
+ ciphertext_blob: val
118
+ ).plaintext
119
+ end
120
+ [val, is_kms]
121
+ end
122
+ @local_items
123
+ end
124
+
125
+ def local_items_from_file
126
+ if erb_enabled?
127
+ loaded_file = YAML.load(ERB.new(File.read(self.base_path)).result)
128
+ else
129
+ loaded_file = YAML.load_file(self.base_path)
130
+ end
131
+
132
+ flatten_hash(nil, loaded_file)
133
+ end
134
+
135
+ def load_local_file(local_file)
136
+ file = File.read(local_file)
137
+
138
+ if self.chomp?
139
+ encoded_file = file.chomp.force_encoding(Encoding::ASCII_8BIT)
140
+ else
141
+ encoded_file = file.force_encoding(Encoding::ASCII_8BIT)
142
+ end
143
+
144
+ return ERB.new(encoded_file).result if erb_enabled?
145
+ encoded_file
146
+ end
147
+
148
+ def remote_items
149
+ return @remote_items if not @remote_items.nil?
150
+ @remote_items = {}
151
+
152
+ resp = self.ssm.get_parameters_by_path(
153
+ path: self.prefix,
154
+ recursive: true,
155
+ with_decryption: true
156
+ )
157
+
158
+ return @remote_items if resp.values.nil?
159
+ resp.flat_map(&:parameters).each do |param|
160
+ @remote_items[param.name.gsub(self.prefix, '')] = [(param.value.nil? ? '' : param.value), param.type == 'SecureString']
161
+ end
162
+
163
+ @remote_items
164
+ end
165
+
166
+ def diff(mode)
167
+ Paramsync::Diff.new(target: self, local: self.local_items, remote: self.remote_items, mode: mode)
168
+ end
169
+
170
+ private def flatten_hash(prefix, hash)
171
+ new_hash = {}
172
+
173
+ hash.each do |k, v|
174
+ if k == '_' && !prefix.nil?
175
+ new_key = prefix
176
+ else
177
+ new_key = [prefix, k].compact.join('.').gsub('/.', '/')
178
+ end
179
+
180
+ case v
181
+ when Hash
182
+ new_hash.merge!(flatten_hash(new_key, v))
183
+ else
184
+ new_hash[new_key] = v.to_s
185
+ end
186
+ end
187
+
188
+ new_hash
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,5 @@
1
+ # This software is public domain. No rights are reserved. See LICENSE for more information.
2
+
3
+ class Paramsync
4
+ VERSION = "0.1.0"
5
+ end
data/paramsync.gemspec ADDED
@@ -0,0 +1,31 @@
1
+ require_relative 'lib/paramsync/version.rb'
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 'paramsync'
5
+ s.version = Paramsync::VERSION
6
+ s.authors = ['David Adams', 'Jacob Burroughs']
7
+ s.email = 'maths22@gmail.com'
8
+ s.date = Time.now.strftime('%Y-%m-%d')
9
+ s.license = 'CC0'
10
+ s.homepage = 'https://github.com/maths22/paramsync'
11
+ s.required_ruby_version = '>=2.4.0'
12
+
13
+ s.summary = 'Simple filesystem-to-aws parameter store synchronization'
14
+ s.description =
15
+ 'Syncs content from the filesystem to the aws parameter store. Derived from constancy for consul'
16
+
17
+ s.require_paths = ['lib']
18
+ s.files = Dir["lib/**/*.rb"] + [
19
+ 'bin/paramsync',
20
+ 'README.md',
21
+ 'LICENSE',
22
+ 'paramsync.gemspec'
23
+ ]
24
+ s.bindir = 'bin'
25
+ s.executables = ['paramsync']
26
+
27
+ s.add_dependency 'aws-sdk-ssm', '~> 1.89.0'
28
+ s.add_dependency 'aws-sdk-kms', '~> 1.37.0'
29
+
30
+ s.add_development_dependency 'rspec', '~> 3.0'
31
+ end
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: paramsync
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - David Adams
8
+ - Jacob Burroughs
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2021-02-22 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: aws-sdk-ssm
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: 1.89.0
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: 1.89.0
28
+ - !ruby/object:Gem::Dependency
29
+ name: aws-sdk-kms
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: 1.37.0
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: 1.37.0
42
+ - !ruby/object:Gem::Dependency
43
+ name: rspec
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - "~>"
47
+ - !ruby/object:Gem::Version
48
+ version: '3.0'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - "~>"
54
+ - !ruby/object:Gem::Version
55
+ version: '3.0'
56
+ description: Syncs content from the filesystem to the aws parameter store. Derived
57
+ from constancy for consul
58
+ email: maths22@gmail.com
59
+ executables:
60
+ - paramsync
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - LICENSE
65
+ - README.md
66
+ - bin/paramsync
67
+ - lib/paramsync.rb
68
+ - lib/paramsync/cli.rb
69
+ - lib/paramsync/cli/check_command.rb
70
+ - lib/paramsync/cli/config_command.rb
71
+ - lib/paramsync/cli/encrypt_command.rb
72
+ - lib/paramsync/cli/pull_command.rb
73
+ - lib/paramsync/cli/push_command.rb
74
+ - lib/paramsync/cli/targets_command.rb
75
+ - lib/paramsync/config.rb
76
+ - lib/paramsync/diff.rb
77
+ - lib/paramsync/sync_target.rb
78
+ - lib/paramsync/version.rb
79
+ - paramsync.gemspec
80
+ homepage: https://github.com/maths22/paramsync
81
+ licenses:
82
+ - CC0
83
+ metadata: {}
84
+ post_install_message:
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: 2.4.0
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 3.1.4
100
+ signing_key:
101
+ specification_version: 4
102
+ summary: Simple filesystem-to-aws parameter store synchronization
103
+ test_files: []