octofacts-updater 0.5.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,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 05efcd40e8410c43eb2fd197165de57b754d0e28
4
+ data.tar.gz: ad65e6ce8d288593e0b2b03075fcda6f52fbd4cb
5
+ SHA512:
6
+ metadata.gz: 507c0eecd6dd8c7c281e6734f1dbdde0731ee2e8cfcedd2bc5f9d7fc68fc7805207d5032d3ffc1d20df0e75154afb3786e0ff6f93d03af9e271592d9d45996ec
7
+ data.tar.gz: e017a38aa6681a768d06cc085f4dc4480e4ef736d6782fd6f05a9d2b24b391bc5911b9e0d47791f080ea3321c96b3117f44cd864e60ba2108f95c22c675e60f7
@@ -0,0 +1 @@
1
+ 0.5.0
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "octofacts_updater"
5
+ cli = OctofactsUpdater::CLI.new(ARGV)
6
+ cli.run
@@ -0,0 +1,19 @@
1
+ require "octofacts_updater/cli"
2
+ require "octofacts_updater/fact"
3
+ require "octofacts_updater/fact_index"
4
+ require "octofacts_updater/fixture"
5
+ require "octofacts_updater/plugin"
6
+ require "octofacts_updater/plugins/ip"
7
+ require "octofacts_updater/plugins/ssh"
8
+ require "octofacts_updater/plugins/static"
9
+ require "octofacts_updater/service/base"
10
+ require "octofacts_updater/service/enc"
11
+ require "octofacts_updater/service/github"
12
+ require "octofacts_updater/service/local_file"
13
+ require "octofacts_updater/service/puppetdb"
14
+ require "octofacts_updater/service/ssh"
15
+ require "octofacts_updater/version"
16
+
17
+ module OctofactsUpdater
18
+ #
19
+ end
@@ -0,0 +1,239 @@
1
+ # :nocov:
2
+ require "optparse"
3
+
4
+ module OctofactsUpdater
5
+ class CLI
6
+ # Constructor.
7
+ #
8
+ # argv - The Array with command line arguments.
9
+ def initialize(argv)
10
+ @opts = {}
11
+ OptionParser.new(argv) do |opts|
12
+ opts.banner = "Usage: octofacts-updater [options]"
13
+
14
+ opts.on("-a", "--action <action>", String, "Action to take") do |a|
15
+ @opts[:action] = a
16
+ end
17
+
18
+ opts.on("-c", "--config <config_file>", String, "Path to configuration file") do |f|
19
+ raise "Invalid configuration file" unless File.file?(f)
20
+ @opts[:config] = f
21
+ end
22
+
23
+ opts.on("-H", "--hostname <hostname>", String, "FQDN of the host whose facts are to be gathered") do |h|
24
+ @opts[:hostname] = h
25
+ end
26
+
27
+ opts.on("-o", "--output-file <filename>", String, "Path to output file to write") do |i|
28
+ @opts[:output_file] = i
29
+ end
30
+
31
+ opts.on("-l", "--list <host1,host2,...>", Array, "List of hosts to update or index") do |l|
32
+ @opts[:host_list] = l
33
+ end
34
+
35
+ opts.on("--[no-]quick", "Quick indexing: Use existing YAML fact fixtures when available") do |q|
36
+ @opts[:quick] = q
37
+ end
38
+
39
+ opts.on("-p", "--path <directory>", "Path where to read/write host fixtures when working in bulk") do |path|
40
+ @opts[:path] = path
41
+ end
42
+
43
+ opts.on("--github", "Push any changes to a branch on GitHub (requires --action=bulk)") do
44
+ @opts[:github] ||= {}
45
+ @opts[:github][:enabled] = true
46
+ end
47
+
48
+ opts.on("--datasource <datasource>", "Specify the data source to use when retrieving facts (localfile, puppetdb, ssh)") do |ds|
49
+ unless %w{localfile puppetdb ssh}.include?(ds)
50
+ raise ArgumentError, "Invalid datasource #{ds.inspect}. Acceptable values: localfile, puppetdb, ssh."
51
+ end
52
+ @opts[:datasource] = ds.to_sym
53
+ end
54
+
55
+ opts.on("--config-override <section:key=value>", Array, "Override a portion of the configuration") do |co_array|
56
+ co_array.each do |co|
57
+ if co =~ /\A(\w+):(\S+?)=(.+?)\z/
58
+ @opts[Regexp.last_match(1).to_sym] ||= {}
59
+ @opts[Regexp.last_match(1).to_sym][Regexp.last_match(2).to_sym] = Regexp.last_match(3)
60
+ else
61
+ raise ArgumentError, "Malformed argument: --config-override must be in the format section:key=value"
62
+ end
63
+ end
64
+ end
65
+ end.parse!
66
+ validate_cli
67
+ end
68
+
69
+ def usage
70
+ puts "Usage: octofacts-updater --action <action> [--config-file /path/to/config.yaml] [other options]"
71
+ puts ""
72
+ puts "Available actions:"
73
+ puts " bulk: Update fixtures and index in bulk"
74
+ puts " facts: Obtain facts for one node (requires --hostname <hostname>)"
75
+ puts ""
76
+ end
77
+
78
+ # Run method. Call this to run the octofacts updater with the object that was
79
+ # previously construcuted.
80
+ def run
81
+ unless opts[:action]
82
+ usage
83
+ exit 255
84
+ end
85
+
86
+ @config = {}
87
+
88
+ if opts[:config]
89
+ @config = YAML.load_file(opts[:config])
90
+ substitute_relative_paths!(@config, File.dirname(opts[:config]))
91
+ load_plugins(@config["plugins"]) if @config.key?("plugins")
92
+ end
93
+
94
+ @config[:options] = {}
95
+ opts.each do |k, v|
96
+ if v.is_a?(Hash)
97
+ @config[k.to_s] ||= {}
98
+ v.each do |v_key, v_val|
99
+ @config[k.to_s][v_key.to_s] = v_val
100
+ @config[k.to_s].delete(v_key.to_s) if v_val.nil?
101
+ end
102
+ else
103
+ @config[:options][k] = v
104
+ end
105
+ end
106
+
107
+ return handle_action_bulk if opts[:action] == "bulk"
108
+ return handle_action_facts if opts[:action] == "facts"
109
+
110
+ usage
111
+ exit 255
112
+ end
113
+
114
+ def substitute_relative_paths!(object_in, basedir)
115
+ if object_in.is_a?(Hash)
116
+ object_in.each { |k, v| object_in[k] = substitute_relative_paths!(v, basedir) }
117
+ elsif object_in.is_a?(Array)
118
+ object_in.map! { |v| substitute_relative_paths!(v, basedir) }
119
+ elsif object_in.is_a?(String)
120
+ if object_in =~ %r{^\.\.?(/|\z)}
121
+ object_in = File.expand_path(object_in, basedir)
122
+ end
123
+ object_in
124
+ else
125
+ object_in
126
+ end
127
+ end
128
+
129
+ def handle_action_bulk
130
+ facts_to_index = @config.fetch("index", {})["indexed_facts"]
131
+ unless facts_to_index.is_a?(Array)
132
+ raise ArgumentError, "Must declare index:indexed_facts in configuration to use bulk update"
133
+ end
134
+
135
+ nodes = if opts[:host_list]
136
+ opts[:host_list]
137
+ elsif opts[:hostname]
138
+ [opts[:hostname]]
139
+ else
140
+ OctofactsUpdater::FactIndex.load_file(index_file).nodes(true)
141
+ end
142
+ if nodes.empty?
143
+ raise ArgumentError, "Cannot run bulk update with no nodes to check"
144
+ end
145
+
146
+ path = opts[:path] || @config.fetch("index", {})["node_path"]
147
+ paths = []
148
+
149
+ fixtures = nodes.map do |hostname|
150
+ if opts[:quick] && path && File.file?(File.join(path, "#{hostname}.yaml"))
151
+ OctofactsUpdater::Fixture.load_file(hostname, File.join(path, "#{hostname}.yaml"))
152
+ else
153
+ fixture = OctofactsUpdater::Fixture.make(hostname, @config)
154
+ if path && File.directory?(path)
155
+ fixture.write_file(File.join(path, "#{hostname}.yaml"))
156
+ paths << File.join(path, "#{hostname}.yaml")
157
+ end
158
+ fixture
159
+ end
160
+ end
161
+
162
+ index = OctofactsUpdater::FactIndex.load_file(index_file)
163
+ index.reindex(facts_to_index, fixtures)
164
+ index.write_file
165
+ paths << index_file
166
+
167
+ if opts[:github] && opts[:github][:enabled]
168
+ OctofactsUpdater::Service::GitHub.run(config["github"]["base_directory"], paths, @config)
169
+ end
170
+ end
171
+
172
+ def handle_action_facts
173
+ unless opts[:hostname]
174
+ raise ArgumentError, "--hostname <hostname> must be specified to use --action facts"
175
+ end
176
+
177
+ facts_for_one_node
178
+ end
179
+
180
+ private
181
+
182
+ attr_reader :config, :opts
183
+
184
+ # Determine the facts for one node and print to the console or write to the specified file.
185
+ def facts_for_one_node
186
+ fixture = OctofactsUpdater::Fixture.make(opts[:hostname], @config)
187
+ print_or_write(fixture.to_yaml)
188
+ end
189
+
190
+ # Get the index file from the options or configuration file. Raise error if it does not exist or
191
+ # was not specified.
192
+ def index_file
193
+ @index_file ||= begin
194
+ if config.fetch("index", {})["file"]
195
+ return config["index"]["file"] if File.file?(config["index"]["file"])
196
+ raise Errno::ENOENT, "Index file (#{config['index']['file'].inspect}) does not exist"
197
+ end
198
+ raise ArgumentError, "No index file specified on command line (--index-file) or in configuration file"
199
+ end
200
+ end
201
+
202
+ # Load plugins as per configuration file. Note: all plugins embedded in this gem are automatically
203
+ # loaded. This is just for user-specified plugins.
204
+ #
205
+ # plugins - An Array of file names to load
206
+ def load_plugins(plugins)
207
+ unless plugins.is_a?(Array)
208
+ raise ArgumentError, "load_plugins expects an array, got #{plugins.inspect}"
209
+ end
210
+
211
+ plugins.each do |plugin|
212
+ plugin_file = plugin.start_with?("/") ? plugin : File.expand_path("../../#{plugin}", File.dirname(__FILE__))
213
+ unless File.file?(plugin_file)
214
+ raise Errno::ENOENT, "Failed to find plugin #{plugin.inspect} at #{plugin_file}"
215
+ end
216
+ require plugin_file
217
+ end
218
+ end
219
+
220
+ # Print or write to file depending on whether or not the output file was set.
221
+ #
222
+ # data - Data to print or write.
223
+ def print_or_write(data)
224
+ if opts[:output_file]
225
+ File.open(opts[:output_file], "w") { |f| f.write(data) }
226
+ else
227
+ puts data
228
+ end
229
+ end
230
+
231
+ # Validate command line options. Kick out invalid combinations of options immediately.
232
+ def validate_cli
233
+ if opts[:path] && !File.directory?(opts[:path])
234
+ raise Errno::ENOENT, "An existing directory must be specified with -p/--path"
235
+ end
236
+ end
237
+ end
238
+ end
239
+ # :nocov:
@@ -0,0 +1,145 @@
1
+ # This class represents a fact, either structured or unstructured.
2
+ # The fact has a name and a value. The name is a string, and the value
3
+ # can either be a string/integer/boolean (unstructured) or a hash (structured).
4
+ # This class also has methods used to deal with structured facts (in particular, allowing
5
+ # representation of a structure delimited with ::).
6
+
7
+ module OctofactsUpdater
8
+ class Fact
9
+ attr_reader :name
10
+
11
+ # Constructor.
12
+ #
13
+ # name - The String naming the fact.
14
+ # value - The arbitrary object with the value of the fact.
15
+ def initialize(name, value)
16
+ @name = name
17
+ @value = value
18
+ end
19
+
20
+ # Get the value of the fact. If the name is specified, this will dig into a structured fact to pull
21
+ # out the value within the structure.
22
+ #
23
+ # name_in - An optional String to dig into the structure (formatted with :: indicating hash delimiters)
24
+ #
25
+ # Returns the value of the fact.
26
+ def value(name_in = nil)
27
+ # Just a normal lookup -- return the value
28
+ return @value if name_in.nil?
29
+
30
+ # Structured lookup returns nil unless the fact is actually structured.
31
+ return unless @value.is_a?(Hash)
32
+
33
+ # Dig into the hash to pull out the desired value.
34
+ pointer = @value
35
+ parts = name_in.split("::")
36
+ last_part = parts.pop
37
+
38
+ parts.each do |part|
39
+ return unless pointer[part].is_a?(Hash)
40
+ pointer = pointer[part]
41
+ end
42
+
43
+ pointer[last_part]
44
+ end
45
+
46
+ # Set the value of the fact.
47
+ #
48
+ # new_value - An object with the new value for the fact
49
+ def value=(new_value)
50
+ set_value(new_value)
51
+ end
52
+
53
+ # Set the value of the fact. If the name is specified, this will dig into a structured fact to set
54
+ # the value within the structure.
55
+ #
56
+ # new_value - An object with the new value for the fact
57
+ # name_in - An optional String to dig into the structure (formatted with :: indicating hash delimiters)
58
+ def set_value(new_value, name_in = nil)
59
+ if name_in.nil?
60
+ if new_value.is_a?(Proc)
61
+ return @value = new_value.call(@value)
62
+ end
63
+
64
+ return @value = new_value
65
+ end
66
+
67
+ parts = if name_in.is_a?(String)
68
+ name_in.split("::")
69
+ elsif name_in.is_a?(Array)
70
+ name_in.map do |item|
71
+ if item.is_a?(String)
72
+ item
73
+ elsif item.is_a?(Hash) && item.key?("regexp")
74
+ Regexp.new(item["regexp"])
75
+ else
76
+ raise ArgumentError, "Unable to interpret structure item: #{item.inspect}"
77
+ end
78
+ end
79
+ else
80
+ raise ArgumentError, "Unable to interpret structure: #{name_in.inspect}"
81
+ end
82
+
83
+ set_structured_value(@value, parts, new_value)
84
+ end
85
+
86
+ private
87
+
88
+ # Set a value in the data structure of a structured fact. This is intended to be
89
+ # called recursively.
90
+ #
91
+ # subhash - The Hash, part of the fact, being operated upon
92
+ # parts - The Array to dig in to the hash
93
+ # value - The value to set the ultimate last part to
94
+ #
95
+ # Does not return anything, but modifies 'subhash'
96
+ def set_structured_value(subhash, parts, value)
97
+ return if subhash.nil?
98
+ raise ArgumentError, "Cannot set structured value at #{parts.first.inspect}" unless subhash.is_a?(Hash)
99
+ raise ArgumentError, "parts must be an Array, got #{parts.inspect}" unless parts.is_a?(Array)
100
+
101
+ # At the top level, find all keys that match the first item in the parts.
102
+ matching_keys = subhash.keys.select do |key|
103
+ if parts.first.is_a?(String)
104
+ key == parts.first
105
+ elsif parts.first.is_a?(Regexp)
106
+ parts.first.match(key)
107
+ else
108
+ # :nocov:
109
+ # This is a bug - this code should be unreachable because of the checking in `set_value`
110
+ raise ArgumentError, "part must be a string or regexp, got #{parts.first.inspect}"
111
+ # :nocov:
112
+ end
113
+ end
114
+
115
+ # Auto-create a new hash if there is a value, the part is a string, and the key doesn't exist.
116
+ if parts.first.is_a?(String) && !value.nil? && !subhash.key?(parts.first)
117
+ subhash[parts.first] = {}
118
+ matching_keys << parts.first
119
+ end
120
+ return unless matching_keys.any?
121
+
122
+ # If we are at the end, set the value or delete the key.
123
+ if parts.size == 1
124
+ if value.nil?
125
+ matching_keys.each { |k| subhash.delete(k) }
126
+ elsif value.is_a?(Proc)
127
+ matching_keys.each do |k|
128
+ new_value = value.call(subhash[k])
129
+ if new_value.nil?
130
+ subhash.delete(k)
131
+ else
132
+ subhash[k] = new_value
133
+ end
134
+ end
135
+ else
136
+ matching_keys.each { |k| subhash[k] = value }
137
+ end
138
+ return
139
+ end
140
+
141
+ # We are not at the end. Recurse down to the next level.
142
+ matching_keys.each { |k| set_structured_value(subhash[k], parts[1..-1], value) }
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,164 @@
1
+ # This class represents a fact index, which is ultimately represented by a YAML file of
2
+ # each index fact, the values seen, and the node(s) containing each value.
3
+ #
4
+ # fact_one:
5
+ # value_one:
6
+ # - node-1.example.net
7
+ # - node-2.example.net
8
+ # value_three:
9
+ # - node-3.example.net
10
+ # fact_two:
11
+ # value_abc:
12
+ # - node-1.example.net
13
+ # value_def:
14
+ # - node-2.example.net
15
+ # - node-3.example.net
16
+
17
+ require "set"
18
+ require "yaml"
19
+
20
+ module OctofactsUpdater
21
+ class FactIndex
22
+ # We will create a pseudo-fact that simply lists all of the nodes that were considered
23
+ # in the index. Define the name of that pseudo-fact here.
24
+ TOP_LEVEL_NODES_KEY = "_nodes".freeze
25
+
26
+ attr_reader :index_data
27
+
28
+ # Load an index from the YAML file.
29
+ #
30
+ # filename - A String with the file to be loaded.
31
+ #
32
+ # Returns a OctofactsUpdater::FactIndex object.
33
+ def self.load_file(filename)
34
+ unless File.file?(filename)
35
+ raise Errno::ENOENT, "load_index cannot load #{filename.inspect}"
36
+ end
37
+
38
+ data = YAML.safe_load(File.read(filename))
39
+ new(data, filename: filename)
40
+ end
41
+
42
+ # Constructor.
43
+ #
44
+ # data - A Hash of existing index data.
45
+ # filename - Optionally, a String with a file name to write the index to
46
+ def initialize(data = {}, filename: nil)
47
+ @index_data = data
48
+ @filename = filename
49
+ end
50
+
51
+ # Add a fact to the index. If the fact already exists in the index, this will overwrite it.
52
+ #
53
+ # fact_name - A String with the name of the fact
54
+ # fixtures - An Array with fact fixtures (must respond to .facts and .hostname)
55
+ def add(fact_name, fixtures)
56
+ @index_data[fact_name] ||= {}
57
+ fixtures.each do |fixture|
58
+ fact_value = get_fact(fixture, fact_name)
59
+ next if fact_value.nil?
60
+ @index_data[fact_name][fact_value] ||= []
61
+ @index_data[fact_name][fact_value] << fixture.hostname
62
+ end
63
+ end
64
+
65
+ # Get a list of all of the nodes in the index. This supports a quick mode (default) where the
66
+ # TOP_LEVEL_NODES_KEY key is used, and a more detailed mode where this digs through each indexed
67
+ # fact and value to build a list of nodes.
68
+ #
69
+ # quick_mode - Boolean whether to use quick mode (default=true)
70
+ #
71
+ # Returns an Array of nodes whose facts are indexed.
72
+ def nodes(quick_mode = true)
73
+ if quick_mode && @index_data.key?(TOP_LEVEL_NODES_KEY)
74
+ return @index_data[TOP_LEVEL_NODES_KEY]
75
+ end
76
+
77
+ seen_hosts = Set.new
78
+ @index_data.each do |fact_name, fact_values|
79
+ next if fact_name == TOP_LEVEL_NODES_KEY
80
+ fact_values.each do |_fact_value, nodes|
81
+ seen_hosts.merge(nodes)
82
+ end
83
+ end
84
+ seen_hosts.to_a.sort
85
+ end
86
+
87
+ # Rebuild an index with a specified list of facts. This will remove any indexed facts that
88
+ # are not on the list of facts to use.
89
+ #
90
+ # facts_to_index - An Array of Strings with facts to index
91
+ # fixtures - An Array with fact fixtures (must respond to .facts and .hostname)
92
+ def reindex(facts_to_index, fixtures)
93
+ @index_data = {}
94
+ facts_to_index.each { |fact| add(fact, fixtures) }
95
+ set_top_level_nodes_fact(fixtures)
96
+ end
97
+
98
+ # Create the top level nodes pseudo-fact.
99
+ #
100
+ # fixtures - An Array with fact fixtures (must respond to .hostname)
101
+ def set_top_level_nodes_fact(fixtures)
102
+ @index_data[TOP_LEVEL_NODES_KEY] = fixtures.map { |f| f.hostname }.sort
103
+ end
104
+
105
+ # Get YAML representation of the index.
106
+ # This sorts the hash and any arrays without modifying the object.
107
+ def to_yaml
108
+ YAML.dump(recursive_sort(index_data))
109
+ end
110
+
111
+ def recursive_sort(object_in)
112
+ if object_in.is_a?(Hash)
113
+ object_out = {}
114
+ object_in.keys.sort.each { |k| object_out[k] = recursive_sort(object_in[k]) }
115
+ object_out
116
+ elsif object_in.is_a?(Array)
117
+ object_in.sort.map { |v| recursive_sort(v) }
118
+ else
119
+ object_in
120
+ end
121
+ end
122
+
123
+ # Write the fact index out to a YAML file.
124
+ #
125
+ # filename - A String with the file to write (defaults to filename from constructor if available)
126
+ def write_file(filename = nil)
127
+ filename ||= @filename
128
+ unless filename.is_a?(String)
129
+ raise ArgumentError, "Called write_file() for fact_index without a filename"
130
+ end
131
+ File.open(filename, "w") { |f| f.write(to_yaml) }
132
+ end
133
+
134
+ private
135
+
136
+ # Extract a (possibly) structured fact.
137
+ #
138
+ # fixture - Fact fixture, must respond to .facts
139
+ # fact_name - A String with the name of the fact
140
+ #
141
+ # Returns the value of the fact, or nil if fact or structure does not exist.
142
+ def get_fact(fixture, fact_name)
143
+ pointer = fixture.facts
144
+
145
+ # Get the fact of interest from the fixture, whether structured or not.
146
+ components = fact_name.split(".")
147
+ first_component = components.shift
148
+ return unless pointer.key?(first_component)
149
+
150
+ # For simple non-structured facts, just return the value.
151
+ return pointer[first_component].value if components.empty?
152
+
153
+ # Structured facts: dig into the structure.
154
+ pointer = pointer[first_component].value
155
+ last_component = components.pop
156
+ components.each do |part|
157
+ return unless pointer.key?(part)
158
+ return unless pointer[part].is_a?(Hash)
159
+ pointer = pointer[part]
160
+ end
161
+ pointer[last_component]
162
+ end
163
+ end
164
+ end