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.
- checksums.yaml +7 -0
- data/.version +1 -0
- data/bin/octofacts-updater +6 -0
- data/lib/octofacts_updater.rb +19 -0
- data/lib/octofacts_updater/cli.rb +239 -0
- data/lib/octofacts_updater/fact.rb +145 -0
- data/lib/octofacts_updater/fact_index.rb +164 -0
- data/lib/octofacts_updater/fixture.rb +136 -0
- data/lib/octofacts_updater/plugin.rb +70 -0
- data/lib/octofacts_updater/plugins/ip.rb +38 -0
- data/lib/octofacts_updater/plugins/ssh.rb +23 -0
- data/lib/octofacts_updater/plugins/static.rb +53 -0
- data/lib/octofacts_updater/service/base.rb +35 -0
- data/lib/octofacts_updater/service/enc.rb +41 -0
- data/lib/octofacts_updater/service/github.rb +230 -0
- data/lib/octofacts_updater/service/local_file.rb +36 -0
- data/lib/octofacts_updater/service/puppetdb.rb +42 -0
- data/lib/octofacts_updater/service/ssh.rb +58 -0
- data/lib/octofacts_updater/version.rb +3 -0
- metadata +121 -0
@@ -0,0 +1,136 @@
|
|
1
|
+
# This class represents a fact fixture, which is a set of facts along with a node name.
|
2
|
+
# Facts are OctofactsUpdater::Fact objects, and internally are stored as a hash table
|
3
|
+
# with the key being the fact name and the value being the OctofactsUpdater::Fact object.
|
4
|
+
|
5
|
+
require "yaml"
|
6
|
+
|
7
|
+
module OctofactsUpdater
|
8
|
+
class Fixture
|
9
|
+
attr_reader :facts, :hostname
|
10
|
+
|
11
|
+
# Make a fact fixture for the specified host name by consulting data sources
|
12
|
+
# specified in the configuration.
|
13
|
+
#
|
14
|
+
# hostname - A String with the FQDN of the host.
|
15
|
+
# config - A Hash with configuration data.
|
16
|
+
#
|
17
|
+
# Returns the OctofactsUpdater::Fixture object.
|
18
|
+
def self.make(hostname, config)
|
19
|
+
fact_hash = facts_from_configured_datasource(hostname, config)
|
20
|
+
|
21
|
+
if config.key?("enc")
|
22
|
+
enc_data = OctofactsUpdater::Service::ENC.run_enc(hostname, config)
|
23
|
+
if enc_data.key?("parameters")
|
24
|
+
fact_hash.merge! enc_data["parameters"]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
obj = new(hostname, config, fact_hash)
|
29
|
+
obj.execute_plugins!
|
30
|
+
end
|
31
|
+
|
32
|
+
# Get fact hash from the first configured and working data source.
|
33
|
+
#
|
34
|
+
# hostname - A String with the FQDN of the host.
|
35
|
+
# config - A Hash with configuration data.
|
36
|
+
#
|
37
|
+
# Returns a Hash with the facts for the specified node; raises exception if this was not possible.
|
38
|
+
def self.facts_from_configured_datasource(hostname, config)
|
39
|
+
last_exception = nil
|
40
|
+
data_sources = %w(LocalFile PuppetDB SSH)
|
41
|
+
data_sources.each do |ds|
|
42
|
+
next if config.fetch(:options, {})[:datasource] && config[:options][:datasource] != ds.downcase.to_sym
|
43
|
+
next unless config.key?(ds.downcase)
|
44
|
+
clazz = Kernel.const_get("OctofactsUpdater::Service::#{ds}")
|
45
|
+
begin
|
46
|
+
result = clazz.send(:facts, hostname, config)
|
47
|
+
return result["values"] if result["values"].is_a?(Hash)
|
48
|
+
return result
|
49
|
+
rescue => e
|
50
|
+
last_exception = e
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
raise last_exception if last_exception
|
55
|
+
raise ArgumentError, "No fact data sources were configured"
|
56
|
+
end
|
57
|
+
|
58
|
+
# Load a fact fixture from a file. This helps create an index without the more expensive operation
|
59
|
+
# of actually looking up the facts from the data source.
|
60
|
+
#
|
61
|
+
# hostname - A String with the FQDN of the host.
|
62
|
+
# filename - A String with the filename of the existing host.
|
63
|
+
#
|
64
|
+
# Returns the OctofactsUpdater::Fixture object.
|
65
|
+
def self.load_file(hostname, filename)
|
66
|
+
unless File.file?(filename)
|
67
|
+
raise Errno::ENOENT, "Could not load facts from #{filename} because it does not exist"
|
68
|
+
end
|
69
|
+
|
70
|
+
data = YAML.safe_load(File.read(filename))
|
71
|
+
new(hostname, {}, data)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Constructor.
|
75
|
+
#
|
76
|
+
# hostname - A String with the FQDN of the host.
|
77
|
+
# config - A Hash with configuration data.
|
78
|
+
# fact_hash - A Hash with the facts (key = fact name, value = fact value).
|
79
|
+
def initialize(hostname, config, fact_hash = {})
|
80
|
+
@hostname = hostname
|
81
|
+
@config = config
|
82
|
+
@facts = Hash[fact_hash.collect { |k, v| [k, OctofactsUpdater::Fact.new(k, v)] }]
|
83
|
+
end
|
84
|
+
|
85
|
+
# Execute plugins to clean up facts as per configuration. This modifies the value of the facts
|
86
|
+
# stored in this object. Any facts with a value of nil are removed.
|
87
|
+
#
|
88
|
+
# Returns a copy of this object.
|
89
|
+
def execute_plugins!
|
90
|
+
return self unless @config["facts"].is_a?(Hash)
|
91
|
+
|
92
|
+
@config["facts"].each do |fact_tag, args|
|
93
|
+
fact_names(fact_tag, args).each do |fact_name|
|
94
|
+
@facts[fact_name] ||= OctofactsUpdater::Fact.new(fact_name, nil)
|
95
|
+
plugin_name = args.fetch("plugin", "noop")
|
96
|
+
OctofactsUpdater::Plugin.execute(plugin_name, @facts[fact_name], args, @facts)
|
97
|
+
@facts.delete(fact_name) if @facts[fact_name].value.nil?
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
self
|
102
|
+
end
|
103
|
+
|
104
|
+
# Get fact names associated with a particular data structure. Implements:
|
105
|
+
# - Default behavior, where YAML key = fact name
|
106
|
+
# - Regexp behavior, where YAML "regexp" key is used to match against all facts
|
107
|
+
# - Override behavior, where YAML "fact" key overrides whatever is in the tag
|
108
|
+
#
|
109
|
+
# fact_tag - A String with the YAML key
|
110
|
+
# args - A Hash with the arguments
|
111
|
+
#
|
112
|
+
# Returns an Array of Strings with all fact names matched.
|
113
|
+
def fact_names(fact_tag, args = {})
|
114
|
+
return [args["fact"]] if args.key?("fact")
|
115
|
+
return [fact_tag] unless args.key?("regexp")
|
116
|
+
rexp = Regexp.new(args["regexp"])
|
117
|
+
@facts.keys.select { |k| rexp.match(k) }
|
118
|
+
end
|
119
|
+
|
120
|
+
# Write this fixture to a file.
|
121
|
+
#
|
122
|
+
# filename - A String with the filename to write.
|
123
|
+
def write_file(filename)
|
124
|
+
File.open(filename, "w") { |f| f.write(to_yaml) }
|
125
|
+
end
|
126
|
+
|
127
|
+
# YAML representation of the fact fixture.
|
128
|
+
#
|
129
|
+
# Returns a String containing the YAML representation of the fact fixture.
|
130
|
+
def to_yaml
|
131
|
+
sorted_facts = @facts.sort.to_h
|
132
|
+
facts_hash_with_expanded_values = Hash[sorted_facts.collect { |k, v| [k, v.value] }]
|
133
|
+
YAML.dump(facts_hash_with_expanded_values)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# This class provides the base methods for fact manipulation plugins.
|
2
|
+
|
3
|
+
require "digest"
|
4
|
+
|
5
|
+
module OctofactsUpdater
|
6
|
+
class Plugin
|
7
|
+
# Register a plugin.
|
8
|
+
#
|
9
|
+
# plugin_name - A Symbol which is the name of the plugin.
|
10
|
+
# block - A block of code that constitutes the plugin. See sample plugins for expected format.
|
11
|
+
def self.register(plugin_name, &block)
|
12
|
+
@plugins ||= {}
|
13
|
+
if @plugins.key?(plugin_name.to_sym)
|
14
|
+
raise ArgumentError, "A plugin named #{plugin_name} is already registered."
|
15
|
+
end
|
16
|
+
@plugins[plugin_name.to_sym] = block
|
17
|
+
end
|
18
|
+
|
19
|
+
# Execute a plugin
|
20
|
+
#
|
21
|
+
# plugin_name - A Symbol which is the name of the plugin.
|
22
|
+
# fact - An OctofactsUpdater::Fact object
|
23
|
+
# args - An optional Hash of additional configuration arguments
|
24
|
+
# all_facts - A Hash of all of the facts
|
25
|
+
#
|
26
|
+
# Returns nothing, but may adjust the "fact"
|
27
|
+
def self.execute(plugin_name, fact, args = {}, all_facts = {})
|
28
|
+
unless @plugins.key?(plugin_name.to_sym)
|
29
|
+
raise NoMethodError, "A plugin named #{plugin_name} could not be found."
|
30
|
+
end
|
31
|
+
|
32
|
+
begin
|
33
|
+
@plugins[plugin_name.to_sym].call(fact, args, all_facts)
|
34
|
+
rescue => e
|
35
|
+
warn "#{e.class} occurred executing #{plugin_name} on #{fact.name} with value #{fact.value.inspect}"
|
36
|
+
raise e
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Clear out a plugin definition. (Useful for testing.)
|
41
|
+
#
|
42
|
+
# plugin_name - The name of the plugin to clear.
|
43
|
+
def self.clear!(plugin_name)
|
44
|
+
@plugins ||= {}
|
45
|
+
@plugins.delete(plugin_name.to_sym)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Get the plugins hash.
|
49
|
+
def self.plugins
|
50
|
+
@plugins
|
51
|
+
end
|
52
|
+
|
53
|
+
# ---------------------------
|
54
|
+
# Below this point are shared methods intended to be called by plugins.
|
55
|
+
# ---------------------------
|
56
|
+
|
57
|
+
# Randomize a long string. This method accepts a string (consisting of, for example, a SSH key)
|
58
|
+
# and returns a string of the same length, but with randomized characters.
|
59
|
+
#
|
60
|
+
# string_in - A String with the original fact value.
|
61
|
+
#
|
62
|
+
# Returns a String with the same length as string_in.
|
63
|
+
def self.randomize_long_string(string_in)
|
64
|
+
seed = Digest::MD5.hexdigest(string_in).to_i(36)
|
65
|
+
prng = Random.new(seed)
|
66
|
+
chars = [("a".."z"), ("A".."Z"), ("0".."9")].flat_map(&:to_a)
|
67
|
+
(1..(string_in.length)).map { chars[prng.rand(chars.length)] }.join
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# This file is part of the octofacts updater fact manipulation plugins. This plugin provides
|
2
|
+
# methods to update facts that are IP addresses in order to anonymize or randomize them.
|
3
|
+
|
4
|
+
require "ipaddr"
|
5
|
+
|
6
|
+
# ipv4_anonymize. This method modifies an IP (version 4) address and
|
7
|
+
# sets it to a randomized (yet consistent) address in the given
|
8
|
+
# network.
|
9
|
+
#
|
10
|
+
# Supported parameters in args:
|
11
|
+
# - subnet: (Required) The network prefix in CIDR notation
|
12
|
+
OctofactsUpdater::Plugin.register(:ipv4_anonymize) do |fact, args = {}, facts|
|
13
|
+
raise ArgumentError, "ipv4_anonymize requires a subnet" if args["subnet"].nil?
|
14
|
+
|
15
|
+
subnet_range = IPAddr.new(args["subnet"], Socket::AF_INET).to_range
|
16
|
+
# Convert the original IP to an integer representation that we can use as seed
|
17
|
+
seed = IPAddr.new(fact.value(args["structure"]), Socket::AF_INET).to_i
|
18
|
+
srand seed
|
19
|
+
random_ip = IPAddr.new(rand(subnet_range.first.to_i..subnet_range.last.to_i), Socket::AF_INET)
|
20
|
+
fact.set_value(random_ip.to_s, args["structure"])
|
21
|
+
end
|
22
|
+
|
23
|
+
# ipv6_anonymize. This method modifies an IP (version 6) address and
|
24
|
+
# sets it to a randomized (yet consistent) address in the given
|
25
|
+
# network.
|
26
|
+
#
|
27
|
+
# Supported parameters in args:
|
28
|
+
# - subnet: (Required) The network prefix in CIDR notation
|
29
|
+
OctofactsUpdater::Plugin.register(:ipv6_anonymize) do |fact, args = {}, facts|
|
30
|
+
raise ArgumentError, "ipv6_anonymize requires a subnet" if args["subnet"].nil?
|
31
|
+
|
32
|
+
subnet_range = IPAddr.new(args["subnet"], Socket::AF_INET6).to_range
|
33
|
+
# Convert the hostname to an integer representation that we can use as seed
|
34
|
+
seed = IPAddr.new(fact.value(args["structure"]), Socket::AF_INET6).to_i
|
35
|
+
srand seed
|
36
|
+
random_ip = IPAddr.new(rand(subnet_range.first.to_i..subnet_range.last.to_i), Socket::AF_INET6)
|
37
|
+
fact.set_value(random_ip.to_s, args["structure"])
|
38
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# This file is part of the octofacts updater fact manipulation plugins. This plugin provides
|
2
|
+
# methods to update facts that are SSH keys, since we do not desire to commit SSH keys from
|
3
|
+
# actual hosts into the source code repository.
|
4
|
+
|
5
|
+
# sshfp. This method randomizes the secret key for sshfp formatted keys. Each key is replaced
|
6
|
+
# by a randomized (yet consistent) string the same length as the input key.
|
7
|
+
# The input looks like this:
|
8
|
+
# sshfp_ecdsa: |-
|
9
|
+
# SSHFP 3 1 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
10
|
+
# SSHFP 3 2 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
11
|
+
OctofactsUpdater::Plugin.register(:sshfp_randomize) do |fact, args = {}|
|
12
|
+
blk = Proc.new do |val|
|
13
|
+
lines = val.split("\n").map(&:strip)
|
14
|
+
result = lines.map do |line|
|
15
|
+
unless line =~ /\ASSHFP (\d+) (\d+) (\w+)/
|
16
|
+
raise "Unparseable pattern: #{line}"
|
17
|
+
end
|
18
|
+
"SSHFP #{Regexp.last_match(1)} #{Regexp.last_match(2)} #{OctofactsUpdater::Plugin.randomize_long_string(Regexp.last_match(3))}"
|
19
|
+
end
|
20
|
+
result.join("\n")
|
21
|
+
end
|
22
|
+
fact.set_value(blk, args["structure"])
|
23
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# This file is part of the octofacts updater fact manipulation plugins. This plugin provides
|
2
|
+
# methods to do static operations on facts -- delete, add, or set to a known value.
|
3
|
+
|
4
|
+
# Delete. This method deletes the fact or the identified portion. Setting the value to nil
|
5
|
+
# causes the tooling to remove any such portions of the value.
|
6
|
+
#
|
7
|
+
# Supported parameters in args:
|
8
|
+
# - structure: A String or Array of a structure within a structured fact
|
9
|
+
OctofactsUpdater::Plugin.register(:delete) do |fact, args = {}, _all_facts = {}|
|
10
|
+
fact.set_value(nil, args["structure"])
|
11
|
+
end
|
12
|
+
|
13
|
+
# Set. This method sets the fact or the identified portion to a static value.
|
14
|
+
#
|
15
|
+
# Supported parameters in args:
|
16
|
+
# - structure: A String or Array of a structure within a structured fact
|
17
|
+
# - value: The new value to set the fact to
|
18
|
+
OctofactsUpdater::Plugin.register(:set) do |fact, args = {}, _all_facts = {}|
|
19
|
+
fact.set_value(args["value"], args["structure"])
|
20
|
+
end
|
21
|
+
|
22
|
+
# Remove matching objects from a delimited string. Requires that the delimiter
|
23
|
+
# and regular expression be set. This is useful, for example, to transform a
|
24
|
+
# string like `foo,bar,baz,fizz` into `foo,fizz` (by removing /^ba/).
|
25
|
+
#
|
26
|
+
# Supported parameters in args:
|
27
|
+
# - delimiter: (Required) Character that is the delimiter.
|
28
|
+
# - regexp: (Required) String used to construct a regular expression of items to remove
|
29
|
+
OctofactsUpdater::Plugin.register(:remove_from_delimited_string) do |fact, args = {}, _all_facts = {}|
|
30
|
+
unless fact.value.nil?
|
31
|
+
unless args["delimiter"]
|
32
|
+
raise ArgumentError, "remove_from_delimited_string requires a delimiter, got #{args.inspect}"
|
33
|
+
end
|
34
|
+
unless args["regexp"]
|
35
|
+
raise ArgumentError, "remove_from_delimited_string requires a regexp, got #{args.inspect}"
|
36
|
+
end
|
37
|
+
parts = fact.value.split(args["delimiter"])
|
38
|
+
regexp = Regexp.new(args["regexp"])
|
39
|
+
parts.delete_if { |part| regexp.match(part) }
|
40
|
+
fact.set_value(parts.join(args["delimiter"]))
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# No-op. Do nothing at all.
|
45
|
+
OctofactsUpdater::Plugin.register(:noop) do |_fact, _args = {}, _all_facts = {}|
|
46
|
+
#
|
47
|
+
end
|
48
|
+
|
49
|
+
# Randomize long string. This is just a wrapper around OctofactsUpdater::Plugin.randomize_long_string
|
50
|
+
OctofactsUpdater::Plugin.register(:randomize_long_string) do |fact, args = {}, _all_facts = {}|
|
51
|
+
blk = Proc.new { |val| OctofactsUpdater::Plugin.randomize_long_string(val) }
|
52
|
+
fact.set_value(blk, args["structure"])
|
53
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# This contains handy utility methods that might be used in any of the other classes.
|
2
|
+
|
3
|
+
require "yaml"
|
4
|
+
|
5
|
+
module OctofactsUpdater
|
6
|
+
module Service
|
7
|
+
class Base
|
8
|
+
# Parse a YAML fact file from PuppetServer. This removes the header (e.g. "--- !ruby/object:Puppet::Node::Facts")
|
9
|
+
# so that it's not necessary to bring in all of Puppet.
|
10
|
+
#
|
11
|
+
# yaml_string - A String with YAML to parse.
|
12
|
+
#
|
13
|
+
# Returns a Hash with the facts.
|
14
|
+
def self.parse_yaml(yaml_string)
|
15
|
+
# Convert first "---" after any comments and blank lines.
|
16
|
+
yaml_array = yaml_string.to_s.split("\n")
|
17
|
+
yaml_array.each_with_index do |line, index|
|
18
|
+
next if line =~ /\A\s*#/
|
19
|
+
next if line.strip == ""
|
20
|
+
if line.start_with?("---")
|
21
|
+
yaml_array[index] = "---"
|
22
|
+
end
|
23
|
+
break
|
24
|
+
end
|
25
|
+
|
26
|
+
# Parse the YAML file
|
27
|
+
result = YAML.safe_load(yaml_array.join("\n"))
|
28
|
+
|
29
|
+
# Pull out "values" if this is in a name-values format. Otherwise just return the hash.
|
30
|
+
return result["values"] if result["values"].is_a?(Hash)
|
31
|
+
result
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# This class contains methods to interact with an external node classifier.
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
require "shellwords"
|
5
|
+
require "yaml"
|
6
|
+
|
7
|
+
module OctofactsUpdater
|
8
|
+
module Service
|
9
|
+
class ENC
|
10
|
+
# Execute the external node classifier script. This expects the value of "path" to be
|
11
|
+
# set in the configuration.
|
12
|
+
#
|
13
|
+
# hostname - A String with the FQDN of the host.
|
14
|
+
# config - A Hash with configuration data.
|
15
|
+
#
|
16
|
+
# Returns a Hash consisting of the parsed output of the ENC.
|
17
|
+
def self.run_enc(hostname, config)
|
18
|
+
unless config["enc"].is_a?(Hash)
|
19
|
+
raise ArgumentError, "The ENC configuration must be defined"
|
20
|
+
end
|
21
|
+
|
22
|
+
unless config["enc"]["path"].is_a?(String)
|
23
|
+
raise ArgumentError, "The ENC path must be defined"
|
24
|
+
end
|
25
|
+
|
26
|
+
unless File.file?(config["enc"]["path"])
|
27
|
+
raise Errno::ENOENT, "The ENC script could not be found at #{config['enc']['path'].inspect}"
|
28
|
+
end
|
29
|
+
|
30
|
+
command = [config["enc"]["path"], hostname].map { |x| Shellwords.escape(x) }.join(" ")
|
31
|
+
stdout, stderr, exitstatus = Open3.capture3(command)
|
32
|
+
unless exitstatus.exitstatus == 0
|
33
|
+
output = { "stdout" => stdout, "stderr" => stderr, "exitstatus" => exitstatus.exitstatus }
|
34
|
+
raise "Error executing #{command.inspect}: #{output.to_yaml}"
|
35
|
+
end
|
36
|
+
|
37
|
+
YAML.load(stdout)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,230 @@
|
|
1
|
+
# This class contains methods to interact with the GitHub API.
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "octokit"
|
5
|
+
require "pathname"
|
6
|
+
|
7
|
+
module OctofactsUpdater
|
8
|
+
module Service
|
9
|
+
class GitHub
|
10
|
+
attr_reader :options
|
11
|
+
|
12
|
+
# Callable external method: Push all changes to the indicated paths to GitHub.
|
13
|
+
#
|
14
|
+
# root - A String with the root directory, to which paths are relative.
|
15
|
+
# paths - An Array of Strings, which are relative to the repository root.
|
16
|
+
# options - A Hash with configuration options.
|
17
|
+
#
|
18
|
+
# Returns true if there were any changes made, false otherwise.
|
19
|
+
def self.run(root, paths, options = {})
|
20
|
+
root ||= options.fetch("github", {})["base_directory"]
|
21
|
+
unless root && File.directory?(root)
|
22
|
+
raise ArgumentError, "Base directory must be specified"
|
23
|
+
end
|
24
|
+
github = new(options)
|
25
|
+
project_root = Pathname.new(root)
|
26
|
+
paths.each do |path|
|
27
|
+
absolute_path = Pathname.new(path)
|
28
|
+
stripped_path = absolute_path.relative_path_from(project_root)
|
29
|
+
github.commit_data(stripped_path.to_s, File.read(path))
|
30
|
+
end
|
31
|
+
github.finalize_commit
|
32
|
+
end
|
33
|
+
|
34
|
+
# Constructor.
|
35
|
+
#
|
36
|
+
# options - Hash with options
|
37
|
+
def initialize(options = {})
|
38
|
+
@options = options
|
39
|
+
@verbose = github_options.fetch("verbose", false)
|
40
|
+
@changes = []
|
41
|
+
end
|
42
|
+
|
43
|
+
# Commit a file to a location in the repository with the provided message. This will return true if there was
|
44
|
+
# an actual change, and false otherwise. This method does not actually do the commit, but rather it batches up
|
45
|
+
# all of the changes which must be realized later.
|
46
|
+
#
|
47
|
+
# path - A String with the path at which to commit the file
|
48
|
+
# new_content - A String with the new contents
|
49
|
+
#
|
50
|
+
# Returns true (and updates @changes) if there was actually a change, false otherwise.
|
51
|
+
def commit_data(path, new_content)
|
52
|
+
ensure_branch_exists
|
53
|
+
|
54
|
+
old_content = nil
|
55
|
+
begin
|
56
|
+
contents = octokit.contents(repository, path: path, ref: branch)
|
57
|
+
old_content = Base64.decode64(contents.content)
|
58
|
+
rescue Octokit::NotFound
|
59
|
+
verbose("No old content found in #{repository.inspect} at #{path.inspect} in #{branch.inspect}")
|
60
|
+
# Fine, we will add below.
|
61
|
+
end
|
62
|
+
|
63
|
+
if new_content == old_content
|
64
|
+
verbose("Content of #{path} matches, no commit needed")
|
65
|
+
return false
|
66
|
+
else
|
67
|
+
verbose("Content of #{path} does not match. A commit is needed.")
|
68
|
+
verbose(Diffy::Diff.new(old_content, new_content))
|
69
|
+
end
|
70
|
+
|
71
|
+
@changes << Hash(
|
72
|
+
path: path,
|
73
|
+
mode: "100644",
|
74
|
+
type: "blob",
|
75
|
+
sha: octokit.create_blob(repository, new_content)
|
76
|
+
)
|
77
|
+
|
78
|
+
verbose("Batched update of #{path}")
|
79
|
+
true
|
80
|
+
end
|
81
|
+
|
82
|
+
# Finalize the GitHub commit by actually pushing any of the changes. This will not do anything if there
|
83
|
+
# are not any changes batched via the `commit_data` method.
|
84
|
+
#
|
85
|
+
# message - A String with a commit message, defaults to the overall configured commit message.
|
86
|
+
def finalize_commit(message = commit_message)
|
87
|
+
return unless @changes.any?
|
88
|
+
|
89
|
+
ensure_branch_exists
|
90
|
+
branch_ref = octokit.branch(repository, branch)
|
91
|
+
commit = octokit.git_commit(repository, branch_ref[:commit][:sha])
|
92
|
+
tree = commit["tree"]
|
93
|
+
new_tree = octokit.create_tree(repository, @changes, base_tree: tree["sha"])
|
94
|
+
new_commit = octokit.create_commit(repository, message, new_tree["sha"], commit["sha"])
|
95
|
+
octokit.update_ref(repository, "heads/#{branch}", new_commit["sha"])
|
96
|
+
verbose("Committed #{@changes.size} change(s) to GitHub")
|
97
|
+
find_or_create_pull_request
|
98
|
+
end
|
99
|
+
|
100
|
+
# Delete a file from the repository. Because of the way the GitHub API works, this will generate an
|
101
|
+
# immediate commit and push. It will NOT be batched for later application.
|
102
|
+
#
|
103
|
+
# path - A String with the path at which to commit the file
|
104
|
+
# message - A String with a commit message, defaults to the overall configured commit message.
|
105
|
+
#
|
106
|
+
# Returns true if the file existed before and was deleted. Returns false if the file didn't exist anyway.
|
107
|
+
def delete_file(path, message = commit_message)
|
108
|
+
ensure_branch_exists
|
109
|
+
contents = octokit.contents(repository, path: path, ref: branch)
|
110
|
+
blob_sha = contents.sha
|
111
|
+
octokit.delete_contents(repository, path, message, blob_sha, branch: branch)
|
112
|
+
verbose("Deleted #{path}")
|
113
|
+
find_or_create_pull_request
|
114
|
+
true
|
115
|
+
rescue Octokit::NotFound
|
116
|
+
verbose("Deleted #{path} (already gone)")
|
117
|
+
false
|
118
|
+
end
|
119
|
+
|
120
|
+
private
|
121
|
+
|
122
|
+
# Private: Build an octokit object from the provided options.
|
123
|
+
#
|
124
|
+
# Returns an octokit object.
|
125
|
+
def octokit
|
126
|
+
@octokit ||= begin
|
127
|
+
token = options.fetch("github", {})["token"] || ENV["OCTOKIT_TOKEN"]
|
128
|
+
if token
|
129
|
+
Octokit::Client.new(access_token: token)
|
130
|
+
else
|
131
|
+
raise ArgumentError, "Access token must be provided in config file or OCTOKIT_TOKEN environment variable."
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
# Private: Get the default branch from the repository. Unless default_branch is specified in the options, then use
|
137
|
+
# that instead.
|
138
|
+
#
|
139
|
+
# Returns a String with the name of the default branch.
|
140
|
+
def default_branch
|
141
|
+
github_options["default_branch"] || octokit.repo(repository)[:default_branch]
|
142
|
+
end
|
143
|
+
|
144
|
+
# Private: Ensure branch exists. This will use octokit to create the branch on GitHub if the branch
|
145
|
+
# does not already exist.
|
146
|
+
def ensure_branch_exists
|
147
|
+
@ensure_branch_exists ||= begin
|
148
|
+
created = false
|
149
|
+
begin
|
150
|
+
if octokit.branch(repository, branch)
|
151
|
+
verbose("Branch #{branch} already exists in #{repository}.")
|
152
|
+
created = true
|
153
|
+
end
|
154
|
+
rescue Octokit::NotFound
|
155
|
+
# Fine, we'll create it
|
156
|
+
end
|
157
|
+
|
158
|
+
unless created
|
159
|
+
base_sha = octokit.branch(repository, default_branch)[:commit][:sha]
|
160
|
+
octokit.create_ref(repository, "heads/#{branch}", base_sha)
|
161
|
+
verbose("Created branch #{branch} based on #{default_branch} #{base_sha}.")
|
162
|
+
end
|
163
|
+
|
164
|
+
true
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Private: Find an existing pull request for the branch, and commit a new pull request if
|
169
|
+
# there was not an existing one open.
|
170
|
+
#
|
171
|
+
# Returns the pull request object that was created.
|
172
|
+
def find_or_create_pull_request
|
173
|
+
@find_or_create_pull_request ||= begin
|
174
|
+
prs = octokit.pull_requests(repository, head: "github:#{branch}", state: "open")
|
175
|
+
if prs && !prs.empty?
|
176
|
+
verbose("Found existing PR #{prs.first.html_url}")
|
177
|
+
prs.first
|
178
|
+
else
|
179
|
+
new_pr = octokit.create_pull_request(
|
180
|
+
repository,
|
181
|
+
default_branch,
|
182
|
+
branch,
|
183
|
+
pr_subject,
|
184
|
+
pr_body
|
185
|
+
)
|
186
|
+
verbose("Created a new PR #{new_pr.html_url}")
|
187
|
+
new_pr
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
# Simple methods not covered by unit tests explicitly.
|
193
|
+
# :nocov:
|
194
|
+
|
195
|
+
# Log a verbose message.
|
196
|
+
#
|
197
|
+
# message - A String with the message to print.
|
198
|
+
def verbose(message)
|
199
|
+
return unless @verbose
|
200
|
+
puts "*** #{Time.now}: #{message}"
|
201
|
+
end
|
202
|
+
|
203
|
+
def github_options
|
204
|
+
return {} unless options.is_a?(Hash)
|
205
|
+
options.fetch("github", {})
|
206
|
+
end
|
207
|
+
|
208
|
+
def repository
|
209
|
+
github_options.fetch("repository")
|
210
|
+
end
|
211
|
+
|
212
|
+
def branch
|
213
|
+
github_options.fetch("branch")
|
214
|
+
end
|
215
|
+
|
216
|
+
def commit_message
|
217
|
+
github_options.fetch("commit_message")
|
218
|
+
end
|
219
|
+
|
220
|
+
def pr_subject
|
221
|
+
github_options.fetch("pr_subject")
|
222
|
+
end
|
223
|
+
|
224
|
+
def pr_body
|
225
|
+
github_options.fetch("pr_body")
|
226
|
+
end
|
227
|
+
# :nocov:
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|