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.
@@ -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
@@ -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