ncio 0.2.2
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/.gitignore +10 -0
- data/.rspec +2 -0
- data/.rubocop.yml +7 -0
- data/.travis.yml +5 -0
- data/.yardopts +1 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +95 -0
- data/Rakefile +6 -0
- data/bin/console +10 -0
- data/bin/setup +8 -0
- data/exe/ncio +6 -0
- data/lib/ncio.rb +9 -0
- data/lib/ncio/api.rb +7 -0
- data/lib/ncio/api/v1.rb +109 -0
- data/lib/ncio/app.rb +114 -0
- data/lib/ncio/http_client.rb +149 -0
- data/lib/ncio/support.rb +116 -0
- data/lib/ncio/support/option_parsing.rb +200 -0
- data/lib/ncio/support/transform.rb +73 -0
- data/lib/ncio/trollop.rb +863 -0
- data/lib/ncio/version.rb +3 -0
- data/ncio.gemspec +33 -0
- metadata +181 -0
@@ -0,0 +1,149 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'socket'
|
3
|
+
require 'openssl'
|
4
|
+
|
5
|
+
module Ncio
|
6
|
+
##
|
7
|
+
# HttpClient provides a Net::HTTP instance pre-configured to communicate with
|
8
|
+
# the Puppet Node Classification Service. The client will return Ruby native
|
9
|
+
# objects where possible, parsing JSON responses from the service.
|
10
|
+
#
|
11
|
+
# This client implements v1 of the [Node Classification
|
12
|
+
# API](https://docs.puppet.com/pe/2016.1/nc_index.html).
|
13
|
+
class HttpClient
|
14
|
+
attr_reader :host
|
15
|
+
attr_reader :port
|
16
|
+
attr_reader :use_ssl
|
17
|
+
attr_reader :cert
|
18
|
+
attr_reader :key
|
19
|
+
attr_reader :cacert
|
20
|
+
attr_reader :protocol
|
21
|
+
|
22
|
+
# ApiError is raised when there are errors in the REST API repsonse.
|
23
|
+
class ApiError < RuntimeError; end
|
24
|
+
|
25
|
+
ssldir = '/etc/puppetlabs/puppet/ssl'
|
26
|
+
OPTION_DEFAULTS = {
|
27
|
+
host: Socket.gethostname,
|
28
|
+
port: 4433,
|
29
|
+
use_ssl: true,
|
30
|
+
cert: ssldir + '/certs/pe-internal-orchestrator.pem',
|
31
|
+
key: ssldir + '/private_keys/pe-internal-orchestrator.pem',
|
32
|
+
cacert: ssldir + '/certs/ca.pem'
|
33
|
+
}.freeze
|
34
|
+
|
35
|
+
##
|
36
|
+
# initialize a new HttpClient instance
|
37
|
+
#
|
38
|
+
# @param [Hash] opts Options
|
39
|
+
#
|
40
|
+
# @option opts [String] :host The API host, e.g. `"master1.puppet.vm"`.
|
41
|
+
# Defaults to the local hostname returned by `Socket.gethostname`
|
42
|
+
#
|
43
|
+
# @option opts [Fixnum] :port The API tcp port, Defaults to `4433`
|
44
|
+
#
|
45
|
+
# @option opts [String] :cert The path to the PEM encoded client
|
46
|
+
# certificate. Defaults to
|
47
|
+
# `"/etc/puppetlabs/puppet/ssl/certs/pe-internal-orchestrator.pem"`
|
48
|
+
#
|
49
|
+
# @option opts [String] :key The path to the PEM encoded RSA private key
|
50
|
+
# used for the SSL client connection. Defaults to
|
51
|
+
# `"/etc/puppetlabs/puppet/ssl/private_keys/pe-internal-orchestrator.pem"`
|
52
|
+
#
|
53
|
+
# @option opts [String] :cacert The path to the PEM encoded CA certificate
|
54
|
+
# used to authenticate the service URL. Defaults to
|
55
|
+
# `"/etc/puppetlabs/puppet/ssl/certs/ca.pem"`
|
56
|
+
def initialize(opts = {})
|
57
|
+
opts = OPTION_DEFAULTS.merge(opts)
|
58
|
+
@use_ssl = opts[:use_ssl]
|
59
|
+
@host = opts[:host]
|
60
|
+
@port = opts[:port]
|
61
|
+
@cert = opts[:cert]
|
62
|
+
@key = opts[:key]
|
63
|
+
@cacert = opts[:cacert]
|
64
|
+
@protocol = use_ssl ? 'https' : 'http'
|
65
|
+
end
|
66
|
+
|
67
|
+
##
|
68
|
+
# make a request, pass through to Net::HTTP#request
|
69
|
+
#
|
70
|
+
# @param [Net::HTTPRequest] req The HTTP request, e.g. an instance of
|
71
|
+
# `Net::HTTP::Get`, `Net::HTTP::Post`, or `Net::HTTP::Head`.
|
72
|
+
#
|
73
|
+
# @param [String] body The request body, if any.
|
74
|
+
#
|
75
|
+
# @return [Net::HTTPResponse] response
|
76
|
+
def request(req, body = nil)
|
77
|
+
http.request(req, body)
|
78
|
+
end
|
79
|
+
|
80
|
+
##
|
81
|
+
# Provide a URL to the endpoint this client connects to. This is intended
|
82
|
+
# to construct URL's and add query parameters easily.
|
83
|
+
#
|
84
|
+
# @return [URI] the URI of the server this client connects to.
|
85
|
+
def uri
|
86
|
+
return @uri if @uri
|
87
|
+
@uri = URI("#{protocol}://#{host}:#{port}")
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
##
|
93
|
+
# return a memoized HTTP object instance configured with the SSL client
|
94
|
+
# certificate and ready to authorize the peer service
|
95
|
+
#
|
96
|
+
# TODO: Add revocation checking. See: [puppet/ssl/host.rb line
|
97
|
+
# 263](https://github.com/puppetlabs/puppet/blob/4.5.2/lib/puppet/ssl/host.rb#L263)
|
98
|
+
#
|
99
|
+
# @return [Net::HTTP]
|
100
|
+
def http
|
101
|
+
return @http if @http
|
102
|
+
client = Net::HTTP.new(uri.host, uri.port)
|
103
|
+
@http = if use_ssl
|
104
|
+
setup_ssl(client)
|
105
|
+
else
|
106
|
+
client
|
107
|
+
end
|
108
|
+
@http
|
109
|
+
end
|
110
|
+
|
111
|
+
##
|
112
|
+
# Configure this client to use SSL
|
113
|
+
#
|
114
|
+
# @param [Net::HTTP] http The http instance to configure to use SSL
|
115
|
+
#
|
116
|
+
# @return [Net::HTTP] configured with SSL certificates passed to
|
117
|
+
# initializer.
|
118
|
+
def setup_ssl(http)
|
119
|
+
http.use_ssl = true
|
120
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
121
|
+
# Setup the SSL store used for this connection
|
122
|
+
ssl_store = ssl_store()
|
123
|
+
ssl_store.purpose = OpenSSL::X509::PURPOSE_ANY
|
124
|
+
ssl_store.add_file(cacert)
|
125
|
+
http.cert_store = ssl_store
|
126
|
+
# PEM files
|
127
|
+
http.cert = OpenSSL::X509::Certificate.new(read_cert)
|
128
|
+
http.key = OpenSSL::PKey::RSA.new(read_key)
|
129
|
+
http.ca_file = cacert
|
130
|
+
http
|
131
|
+
end
|
132
|
+
|
133
|
+
# helper method to stub the OpenSSL Store
|
134
|
+
def ssl_store
|
135
|
+
OpenSSL::X509::Store.new
|
136
|
+
end
|
137
|
+
##
|
138
|
+
|
139
|
+
# helper method to stub the cert in the tests
|
140
|
+
def read_cert
|
141
|
+
File.read(cert)
|
142
|
+
end
|
143
|
+
|
144
|
+
# helper method to stub the cert in the tests
|
145
|
+
def read_key
|
146
|
+
File.read(key)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
data/lib/ncio/support.rb
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
module Ncio
|
2
|
+
##
|
3
|
+
# Support module to mix into other classes, particularly the application and
|
4
|
+
# API classes. This support module provides a centralized logging
|
5
|
+
# configuration, and common methods to access configuration options about the
|
6
|
+
# behavior of the program.
|
7
|
+
module Support
|
8
|
+
attr_reader :opts
|
9
|
+
|
10
|
+
def self.reset_logging!(opts)
|
11
|
+
out = map_file_option(opts[:logto])
|
12
|
+
logger = Logger.new(out)
|
13
|
+
logger.level = opts[:debug] ? Logger::DEBUG : Logger::INFO
|
14
|
+
@log = logger
|
15
|
+
end
|
16
|
+
|
17
|
+
##
|
18
|
+
# Logging is handled centrally, the helper methods will delegate to the
|
19
|
+
# centrally configured logging instance.
|
20
|
+
def self.log
|
21
|
+
@log
|
22
|
+
end
|
23
|
+
|
24
|
+
##
|
25
|
+
# Map a file option to STDOUT, STDERR or a fully qualified file path.
|
26
|
+
#
|
27
|
+
# @param [String] filepath A relative or fully qualified file path, or the
|
28
|
+
# keyword strings 'STDOUT' or 'STDERR'
|
29
|
+
#
|
30
|
+
# @return [String] file path or $stdout or $sederr
|
31
|
+
def self.map_file_option(filepath)
|
32
|
+
case filepath
|
33
|
+
when 'STDOUT' then $stdout
|
34
|
+
when 'STDERR' then $stderr
|
35
|
+
when 'STDIN' then $stdin
|
36
|
+
else File.expand_path(filepath)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def map_file_option(filepath)
|
41
|
+
Ncio::Support.map_file_option(filepath)
|
42
|
+
end
|
43
|
+
|
44
|
+
def log
|
45
|
+
Ncio::Support.log
|
46
|
+
end
|
47
|
+
|
48
|
+
##
|
49
|
+
# Reset the logging system, requires command line options to have been
|
50
|
+
# parsed.
|
51
|
+
#
|
52
|
+
# @param [Hash<Symbol, String>] Options hash, passed to the support module
|
53
|
+
def reset_logging!(opts)
|
54
|
+
Ncio::Support.reset_logging!(opts)
|
55
|
+
end
|
56
|
+
|
57
|
+
##
|
58
|
+
# Memoized helper method to instantiate an API instance assuming options
|
59
|
+
# are available.
|
60
|
+
def api
|
61
|
+
@api ||= Ncio::Api::V1.new(opts)
|
62
|
+
end
|
63
|
+
|
64
|
+
##
|
65
|
+
# Log an info message
|
66
|
+
def info(msg)
|
67
|
+
log.info msg
|
68
|
+
end
|
69
|
+
|
70
|
+
##
|
71
|
+
# Log a debug message
|
72
|
+
def debug(msg)
|
73
|
+
log.debug msg
|
74
|
+
end
|
75
|
+
|
76
|
+
def uri
|
77
|
+
opts[:uri]
|
78
|
+
end
|
79
|
+
|
80
|
+
def file
|
81
|
+
opts[:file]
|
82
|
+
end
|
83
|
+
|
84
|
+
##
|
85
|
+
# Helper method to write output, used for stubbing out the tests.
|
86
|
+
#
|
87
|
+
# @param [String, IO] output the output path or a IO stream
|
88
|
+
def write_output(str, output)
|
89
|
+
if output.is_a?(IO)
|
90
|
+
output.puts(str)
|
91
|
+
else
|
92
|
+
File.open(output, 'w+') { |f| f.puts(str) }
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
##
|
97
|
+
# Helper method to read from STDIN, or a file and execute an arbitrary block
|
98
|
+
# of code. A block must be passed which will recieve an IO object in the
|
99
|
+
# event input is a readable file path.
|
100
|
+
def input_stream(input)
|
101
|
+
if input.is_a?(IO)
|
102
|
+
yield input
|
103
|
+
else
|
104
|
+
File.open(input, 'r') { |stream| yield stream }
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
##
|
109
|
+
# Return the application version as a Semantic Version encoded string
|
110
|
+
#
|
111
|
+
# @return [String] the version
|
112
|
+
def version
|
113
|
+
Ncio::VERSION
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,200 @@
|
|
1
|
+
require 'ncio/version'
|
2
|
+
# rubocop:disable Metrics/ModuleLength
|
3
|
+
module Ncio
|
4
|
+
module Support
|
5
|
+
##
|
6
|
+
# Support methods intended to be mixed into the application class. These
|
7
|
+
# methods are specific to command line parsing. The model is [GLOBAL
|
8
|
+
# OPTIONS] SUBCOMMAND [SUBCOMMAND OPTIONS]
|
9
|
+
#
|
10
|
+
# Configuration state parsed from options is intended to be stored in a
|
11
|
+
# @opts hash and injected into dependencies, like API instances.
|
12
|
+
module OptionParsing
|
13
|
+
attr_reader :argv, :env, :opts
|
14
|
+
|
15
|
+
##
|
16
|
+
# Reset the @opts instance variable by parsing @argv and @env. Operates
|
17
|
+
# against duplicate copies of the argument vector avoid side effects.
|
18
|
+
#
|
19
|
+
# @return [Hash<Symbol, String>] Options hash
|
20
|
+
def reset_options!
|
21
|
+
@opts = parse_options(argv, env)
|
22
|
+
end
|
23
|
+
|
24
|
+
##
|
25
|
+
# Parse options using the argument vector and the environment hash as
|
26
|
+
# input. Option parsing occurs in two phases, first the global options are
|
27
|
+
# parsed. These are the options specified before the subcommand. The
|
28
|
+
# subcommand, if any, is matched, and subcommand specific options are then
|
29
|
+
# parsed from the remainder of the argument vector.
|
30
|
+
#
|
31
|
+
# @param [Array] argv The argument vector, passed to the option parser.
|
32
|
+
#
|
33
|
+
# @param [Hash] env The environment hash, passed to the option parser to
|
34
|
+
# supply defaults not specified on the command line argument vector.
|
35
|
+
#
|
36
|
+
# @return [Hash<Symbol, String>] options hash
|
37
|
+
def parse_options(argv, env)
|
38
|
+
argv_copy = argv.dup
|
39
|
+
opts = parse_global_options!(argv_copy, env)
|
40
|
+
subcommand = parse_subcommand!(argv_copy)
|
41
|
+
opts[:subcommand] = subcommand
|
42
|
+
sub_opts = parse_subcommand_options!(subcommand, argv_copy, env)
|
43
|
+
opts.merge!(sub_opts)
|
44
|
+
opts
|
45
|
+
end
|
46
|
+
|
47
|
+
##
|
48
|
+
# Parse out the global options, the ones specified between the main
|
49
|
+
# executable and the subcommand argument.
|
50
|
+
#
|
51
|
+
# Modifies argv as a side effect, shifting elements from the array until
|
52
|
+
# the first unknown option is found, which is assumed to be the subcommand
|
53
|
+
# name.
|
54
|
+
#
|
55
|
+
# @return [Hash<Symbol, String>] Global options
|
56
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
57
|
+
def parse_global_options!(argv, env)
|
58
|
+
semver = Ncio::VERSION
|
59
|
+
host = Socket.gethostname
|
60
|
+
Ncio::Trollop.options(argv) do
|
61
|
+
stop_on_unknown
|
62
|
+
version "ncio #{semver} (c) 2016 Jeff McCune"
|
63
|
+
banner BANNER
|
64
|
+
uri_dfl = env['NCIO_URI'] || "https://#{host}:4433/classifier-api/v1"
|
65
|
+
opt :uri, 'Node Classifier service uri '\
|
66
|
+
'{NCIO_URI}', default: uri_dfl
|
67
|
+
opt :cert, CERT_MSG, default: env['NCIO_CERT'] || CERT_DEFAULT
|
68
|
+
opt :key, KEY_MSG, default: env['NCIO_KEY'] || KEY_DEFAULT
|
69
|
+
opt :cacert, CACERT_MSG, default: env['NCIO_CACERT'] || CACERT_DEFAULT
|
70
|
+
log_msg = 'Log file to write to or keywords '\
|
71
|
+
'STDOUT, STDERR {NCIO_LOGTO}'
|
72
|
+
opt :logto, log_msg, default: env['NCIO_LOGTO'] || 'STDERR'
|
73
|
+
opt :debug
|
74
|
+
end
|
75
|
+
end
|
76
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
77
|
+
|
78
|
+
##
|
79
|
+
# Extract the subcommand, if any, from the arguments provided. Modifies
|
80
|
+
# argv as a side effect, shifting the subcommand name if it is present.
|
81
|
+
#
|
82
|
+
# @return [String] The subcommand name, e.g. 'backup' or 'restore', or
|
83
|
+
# false if no arguments remain in the argument vector.
|
84
|
+
def parse_subcommand!(argv)
|
85
|
+
argv.shift || false
|
86
|
+
end
|
87
|
+
|
88
|
+
##
|
89
|
+
# Parse the subcommand options. This method branches out because each
|
90
|
+
# subcommand can have quite different options, unlike global options which
|
91
|
+
# are consistent across all invocations of the application.
|
92
|
+
#
|
93
|
+
# Modifies argv as a side effect, shifting all options as things are
|
94
|
+
# parsed.
|
95
|
+
#
|
96
|
+
# @return [Hash<Symbol, String>] Subcommand specific options hash
|
97
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
98
|
+
def parse_subcommand_options!(subcommand, argv, env)
|
99
|
+
case subcommand
|
100
|
+
when 'backup', 'restore'
|
101
|
+
Ncio::Trollop.options(argv) do
|
102
|
+
banner "Node Classification #{subcommand} options:"
|
103
|
+
groups_msg = 'Operate against NC groups. See: https://goo.gl/QD6ZdW'
|
104
|
+
opt :groups, groups_msg, default: true
|
105
|
+
file_msg = 'File to operate against {NCIO_FILE} or STDOUT, STDERR'
|
106
|
+
file_default = FILE_DEFAULT_MAP[subcommand]
|
107
|
+
opt :file, file_msg, default: env['NCIO_FILE'] || file_default
|
108
|
+
end
|
109
|
+
when 'transform'
|
110
|
+
opts = Ncio::Trollop.options(argv) do
|
111
|
+
banner "Node Classification transformations\n"\
|
112
|
+
'Note: Currently only Monolithic (All-in-one) deployments are '\
|
113
|
+
"supported.\n\nTransformation matches against class names "\
|
114
|
+
'assigned to groups. Transformation of hostnames happen '\
|
115
|
+
'against rules assigned to groups and class parameters for '\
|
116
|
+
"matching classes.\n\nOptions:"
|
117
|
+
hostname_msg = 'Replace the fully qualified domain name on the '\
|
118
|
+
'left with the right, separated with a : '\
|
119
|
+
'e.g --hostname master1.acme.com:master2.acme.com'
|
120
|
+
opt :class_matcher, 'Regexp matching classes assigned to groups. '\
|
121
|
+
'Passed to Regexp.new()',
|
122
|
+
default: '^puppet_enterprise'
|
123
|
+
opt :input, 'Input file path or keywords STDIN, STDOUT, STDERR',
|
124
|
+
default: 'STDIN'
|
125
|
+
opt :output, 'Output file path or keywords STDIN, STDOUT, STDERR',
|
126
|
+
default: 'STDOUT'
|
127
|
+
opt :hostname, hostname_msg, type: :strings, multi: true,
|
128
|
+
required: true
|
129
|
+
end
|
130
|
+
opts[:matcher] = Regexp.new(opts[:class_matcher])
|
131
|
+
if opts[:hostname_given]
|
132
|
+
hsh = map_hostnames(opts[:hostname].flatten)
|
133
|
+
opts.merge!(hostname_map: hsh)
|
134
|
+
end
|
135
|
+
else
|
136
|
+
Ncio::Trollop.die "Unknown subcommand: #{subcommand.inspect}"
|
137
|
+
end
|
138
|
+
end
|
139
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
140
|
+
|
141
|
+
##
|
142
|
+
# Map an array of hostnames separated by a colon. The left is the key of
|
143
|
+
# the map, the right is the value. The Hash returned is "smart" in that a
|
144
|
+
# key that does not exist will return a value matching the key.
|
145
|
+
#
|
146
|
+
# @param [Array<String>] hostnames
|
147
|
+
#
|
148
|
+
# @return [Hash<String, String>] Hash of hostnames, left as key, right as
|
149
|
+
# value.
|
150
|
+
def map_hostnames(hostnames)
|
151
|
+
smart_map = Hash.new { |_, key| key }
|
152
|
+
hostnames.each_with_object(smart_map) do |pair, hsh|
|
153
|
+
(k, v) = pair.split(':')
|
154
|
+
hsh[k] = v
|
155
|
+
hsh
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
BANNER = <<-'EOBANNER'.freeze
|
160
|
+
usage: ncio [GLOBAL OPTIONS] SUBCOMMAND [ARGS]
|
161
|
+
Sub Commands:
|
162
|
+
|
163
|
+
backup Backup Node Classification resources
|
164
|
+
restore Restore Node Classification resources
|
165
|
+
transform Transform a backup, replacing hostnames
|
166
|
+
|
167
|
+
Quick Start: On the host of the Node Classifier service, as root or pe-puppet
|
168
|
+
(to read certs and keys)
|
169
|
+
|
170
|
+
/opt/puppetlabs/puppet/bin/ncio backup > groups.$(date +%s).json
|
171
|
+
/opt/puppetlabs/puppet/bin/ncio restore < groups.1467151827.json
|
172
|
+
|
173
|
+
Transformation:
|
174
|
+
|
175
|
+
ncio --uri https://master1.puppet.vm:4433/classification-api/v1 backup \
|
176
|
+
| ncio transform --hostname master1.puppet.vm:master2.puppet.vm \
|
177
|
+
| ncio --uri https://master2.puppet.vm:4433/classification-api/v1 restore
|
178
|
+
|
179
|
+
Global options: (Note, command line arguments supersede ENV vars in {}'s)
|
180
|
+
EOBANNER
|
181
|
+
|
182
|
+
SSLDIR = '/etc/puppetlabs/puppet/ssl'.freeze
|
183
|
+
CERT_MSG = 'White listed client SSL cert {NCIO_CERT} '\
|
184
|
+
'See: https://goo.gl/zCjncC'.freeze
|
185
|
+
CERT_DEFAULT = (SSLDIR + '/certs/'\
|
186
|
+
'pe-internal-orchestrator.pem').freeze
|
187
|
+
KEY_MSG = 'Client RSA key, must match certificate '\
|
188
|
+
'{NCIO_KEY}'.freeze
|
189
|
+
KEY_DEFAULT = (SSLDIR + '/private_keys/'\
|
190
|
+
'pe-internal-orchestrator.pem').freeze
|
191
|
+
CACERT_MSG = 'CA Cert to authenticate the service uri '\
|
192
|
+
'{NCIO_CACERT}'.freeze
|
193
|
+
CACERT_DEFAULT = (SSLDIR + '/certs/ca.pem').freeze
|
194
|
+
|
195
|
+
# Map is indexed by the subcommand
|
196
|
+
FILE_DEFAULT_MAP = { 'backup' => 'STDOUT', 'restore' => 'STDIN' }.freeze
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
# rubocop:enable Metrics/ModuleLength
|