Skip to content

Commit b8b766c

Browse files
committed
Add Puppetserver v4 catalog API support
1 parent add3459 commit b8b766c

16 files changed

+398
-9
lines changed

lib/octocatalog-diff/catalog.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ def resources
191191
build
192192
raise OctocatalogDiff::Errors::CatalogError, 'Catalog does not appear to have been built' if !valid? && error_message.nil?
193193
raise OctocatalogDiff::Errors::CatalogError, error_message unless valid?
194+
# Handle the structure returned by the /puppet/v4/catalog Puppetserver endpoint:
195+
return @catalog['catalog']['resources'] if @catalog['catalog'].is_a?(Hash) && @catalog['catalog']['resources'].is_a?(Array)
194196
return @catalog['data']['resources'] if @catalog['data'].is_a?(Hash) && @catalog['data']['resources'].is_a?(Array)
195197
return @catalog['resources'] if @catalog['resources'].is_a?(Array)
196198
# This is a bug condition

lib/octocatalog-diff/catalog/puppetmaster.rb

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,19 @@ def build_catalog(logger = Logger.new(StringIO.new))
6262
fetch_catalog(logger)
6363
end
6464

65-
# Returns a hash of parameters for each supported version of the Puppet Server Catalog API.
65+
# Returns a hash of parameters for the requested version of the Puppet Server Catalog API.
6666
# @return [Hash] Hash of parameters
6767
#
6868
# Note: The double escaping of the facts here is implemented to correspond to a long standing
6969
# bug in the Puppet code. See https://github.com/puppetlabs/puppet/pull/1818 and
7070
# https://docs.puppet.com/puppet/latest/http_api/http_catalog.html#parameters for explanation.
71-
def puppet_catalog_api
72-
{
71+
def puppet_catalog_api(version)
72+
api_style = {
7373
2 => {
7474
url: "https://#{@options[:puppet_master]}/#{@options[:branch]}/catalog/#{@node}",
75+
headers: {
76+
'Accept' => 'text/pson'
77+
},
7578
parameters: {
7679
'facts_format' => 'pson',
7780
'facts' => CGI.escape(@facts.fudge_timestamp.without('trusted').to_pson),
@@ -80,24 +83,59 @@ def puppet_catalog_api
8083
},
8184
3 => {
8285
url: "https://#{@options[:puppet_master]}/puppet/v3/catalog/#{@node}",
86+
headers: {
87+
'Accept' => 'text/pson'
88+
},
8389
parameters: {
8490
'environment' => @options[:branch],
8591
'facts_format' => 'pson',
8692
'facts' => CGI.escape(@facts.fudge_timestamp.without('trusted').to_pson),
8793
'transaction_uuid' => SecureRandom.uuid
8894
}
95+
},
96+
4 => {
97+
url: "https://#{@options[:puppet_master]}/puppet/v4/catalog",
98+
headers: {
99+
'Content-Type' => 'application/json'
100+
},
101+
parameters: {
102+
'certname' => @node,
103+
'persistence' => {
104+
'facts' => @options[:puppet_master_update_facts] || false,
105+
'catalog' => @options[:puppet_master_update_catalog] || false
106+
},
107+
'environment' => @options[:branch],
108+
'facts' => { 'values' => @facts.facts['values'] },
109+
'options' => {
110+
'prefer_requested_environment' => true,
111+
'capture_logs' => false,
112+
'log_level' => 'warning'
113+
},
114+
'transaction_uuid' => SecureRandom.uuid
115+
}
89116
}
90117
}
118+
119+
params = api_style[version]
120+
return nil if params.nil?
121+
122+
unless @options[:puppet_master_token].nil?
123+
params[:headers]['X-Authentication'] = @options[:puppet_master_token]
124+
end
125+
126+
params[:parameters] = params[:parameters].to_json if version >= 4
127+
128+
params
91129
end
92130

93131
# Fetch catalog by contacting the Puppet master, sending the facts, and asking for the catalog. When the
94132
# catalog is returned in PSON format, parse it to JSON and then set appropriate variables.
95133
def fetch_catalog(logger)
96134
api_version = @options[:puppet_master_api_version] || DEFAULT_PUPPET_SERVER_API
97-
api = puppet_catalog_api[api_version]
135+
api = puppet_catalog_api(api_version)
98136
raise ArgumentError, "Unsupported or invalid API version #{api_version}" unless api.is_a?(Hash)
99137

100-
more_options = { headers: { 'Accept' => 'text/pson' }, timeout: @timeout }
138+
more_options = { headers: api[:headers], timeout: @timeout }
101139
post_hash = api[:parameters]
102140

103141
response = nil

lib/octocatalog-diff/cli/options/puppet_master_api_version.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ def parse(parser, options)
1414
options: options,
1515
cli_name: 'puppet-master-api-version',
1616
option_name: 'puppet_master_api_version',
17-
desc: 'Puppet Master API version (2 for Puppet 3.x, 3 for Puppet 4.x)',
18-
validator: ->(x) { x =~ /^[23]$/ || raise(ArgumentError, 'Only API versions 2 and 3 are supported') },
17+
desc: 'Puppet Master API version (2 for Puppet 3.x, 3 for Puppet 4.x, 4 for Puppet Server >= 6.3.0)',
18+
validator: ->(x) { x =~ /^[234]$/ || raise(ArgumentError, 'Only API versions 2, 3, and 4 are supported') },
1919
translator: ->(x) { x.to_i }
2020
)
2121
end
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
3+
# Specify a PE RBAC token used to authenticate to Puppetserver for v4
4+
# catalog API calls.
5+
# @param parser [OptionParser object] The OptionParser argument
6+
# @param options [Hash] Options hash being constructed; this is modified in this method.
7+
OctocatalogDiff::Cli::Options::Option.newoption(:puppet_master_token) do
8+
has_weight 310
9+
10+
def parse(parser, options)
11+
OctocatalogDiff::Cli::Options.option_globally_or_per_branch(
12+
parser: parser,
13+
options: options,
14+
datatype: '',
15+
cli_name: 'puppet-master-token',
16+
option_name: 'puppet_master_token',
17+
desc: 'PE RBAC token to authenticate to the Puppetserver API v4'
18+
)
19+
end
20+
end
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# frozen_string_literal: true
2+
3+
# Specify a path to a file containing a PE RBAC token used to authenticate to the
4+
# Puppetserver for a v4 catalog API call.
5+
# @param parser [OptionParser object] The OptionParser argument
6+
# @param options [Hash] Options hash being constructed; this is modified in this method.
7+
OctocatalogDiff::Cli::Options::Option.newoption(:puppet_master_token_file) do
8+
has_weight 300
9+
10+
def parse(parser, options)
11+
OctocatalogDiff::Cli::Options.option_globally_or_per_branch(
12+
parser: parser,
13+
options: options,
14+
datatype: '',
15+
cli_name: 'puppet-master-token-file',
16+
option_name: 'puppet_master_token_file',
17+
desc: 'File containing PE RBAC token to authenticate to the Puppetserver API v4',
18+
translator: ->(x) { x.start_with?('/', '~') ? x : File.join(options[:basedir], x) },
19+
post_process: lambda do |opts|
20+
%w(to from).each do |prefix|
21+
fileopt = "#{prefix}_puppet_master_token_file".to_sym
22+
tokenopt = "#{prefix}_puppet_master_token".to_sym
23+
24+
tokenfile = opts[fileopt]
25+
next if tokenfile.nil?
26+
27+
raise(Errno::ENOENT, "Token file #{tokenfile} is not readable") unless File.readable?(tokenfile)
28+
29+
token = File.read(tokenfile).strip
30+
opts[tokenopt] ||= token
31+
end
32+
end
33+
)
34+
end
35+
end
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
3+
# Specify if, when using the Puppetserver v4 catalog API, the Puppetserver should
4+
# update the catalog in PuppetDB.
5+
# @param parser [OptionParser object] The OptionParser argument
6+
# @param options [Hash] Options hash being constructed; this is modified in this method.
7+
OctocatalogDiff::Cli::Options::Option.newoption(:puppet_master_update_catalog) do
8+
has_weight 320
9+
10+
def parse(parser, options)
11+
OctocatalogDiff::Cli::Options.option_globally_or_per_branch(
12+
parser: parser,
13+
options: options,
14+
datatype: false,
15+
cli_name: 'puppet-master-update-catalog',
16+
option_name: 'puppet_master_update_catalog',
17+
desc: 'Update catalog in PuppetDB when using Puppetmaster API version 4'
18+
)
19+
end
20+
end
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
3+
# Specify if, when using the Puppetserver v4 catalog API, the Puppetserver should
4+
# update the facts in PuppetDB.
5+
# @param parser [OptionParser object] The OptionParser argument
6+
# @param options [Hash] Options hash being constructed; this is modified in this method.
7+
OctocatalogDiff::Cli::Options::Option.newoption(:puppet_master_update_facts) do
8+
has_weight 320
9+
10+
def parse(parser, options)
11+
OctocatalogDiff::Cli::Options.option_globally_or_per_branch(
12+
parser: parser,
13+
options: options,
14+
datatype: false,
15+
cli_name: 'puppet-master-update-facts',
16+
option_name: 'puppet_master_update_facts',
17+
desc: 'Update facts in PuppetDB when using Puppetmaster API version 4'
18+
)
19+
end
20+
end
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"catalog": {
3+
"tags": ["settings"],
4+
"name": "my.rspec.node",
5+
"version": "production",
6+
"code_id": null,
7+
"catalog_uuid": "89869359-db50-472f-b435-1d37c22be9eb",
8+
"catalog_format": 1,
9+
"environment": "production",
10+
"resources": [
11+
{
12+
"type": "Stage",
13+
"title": "main",
14+
"tags": ["stage"],
15+
"exported": false,
16+
"parameters": {
17+
"name": "main"
18+
}
19+
},
20+
{
21+
"type": "Class",
22+
"title": "Settings",
23+
"tags": ["class","settings"],
24+
"exported": false
25+
}
26+
],
27+
"edges": [
28+
{
29+
"source": "Stage[main]",
30+
"target": "Class[Settings]"
31+
},
32+
{
33+
"source": "Stage[main]",
34+
"target": "Class[main]"
35+
}
36+
],
37+
"classes": [
38+
"settings"
39+
]
40+
}
41+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
secretpuppetmastertoken

spec/octocatalog-diff/tests/catalog/puppetmaster_spec.rb

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,19 +73,21 @@
7373
@context = context
7474
{
7575
code: 200,
76-
parsed: JSON.parse(File.read(OctocatalogDiff::Spec.fixture_path('catalogs/tiny-catalog.json')))
76+
parsed: JSON.parse(File.read(OctocatalogDiff::Spec.fixture_path(fixture_catalog)))
7777
}
7878
end
7979
end
8080

8181
let(:api_url) do
8282
{
8383
2 => 'https://fake-puppetmaster.non-existent-domain.com:8140/foobranch/catalog/foo',
84-
3 => 'https://fake-puppetmaster.non-existent-domain.com:8140/puppet/v3/catalog/foo'
84+
3 => 'https://fake-puppetmaster.non-existent-domain.com:8140/puppet/v3/catalog/foo',
85+
4 => 'https://fake-puppetmaster.non-existent-domain.com:8140/puppet/v4/catalog'
8586
}
8687
end
8788

8889
let(:api_sets_environment) { { 2 => false, 3 => true } }
90+
let(:fixture_catalog) { 'catalogs/tiny-catalog.json' }
8991

9092
[2, 3].each do |api_version|
9193
context "api v#{api_version}" do
@@ -100,6 +102,10 @@
100102
expect(@url).to eq(api_url[api_version])
101103
end
102104

105+
it 'should set the Accept header' do
106+
expect(@opts[:headers]['Accept']).to eq('text/pson')
107+
end
108+
103109
it 'should post the correct facts to HTTParty' do
104110
answer = JSON.parse(File.read(OctocatalogDiff::Spec.fixture_path('facts/facts_esc.json')))
105111
answer.delete('_timestamp')
@@ -136,6 +142,90 @@
136142
end
137143
end
138144

145+
context 'api v4' do
146+
let(:fixture_catalog) { 'catalogs/tiny-catalog-v4-api.json' }
147+
let(:extra_opts) { {} }
148+
149+
before(:each) do
150+
opts = { puppet_master_api_version: 4 }
151+
@obj = OctocatalogDiff::Catalog::PuppetMaster.new(valid_options.merge(opts).merge(extra_opts))
152+
@logger, @logger_str = OctocatalogDiff::Spec.setup_logger
153+
@obj.build(@logger)
154+
@parsed_data = JSON.parse(@post_data)
155+
end
156+
157+
it 'should post to the correct URL' do
158+
expect(@url).to eq(api_url[4])
159+
end
160+
161+
it 'should set the Content-Type header correctly' do
162+
expect(@opts[:headers]['Content-Type']).to eq('application/json')
163+
end
164+
165+
it 'should not set the X-Authentication header when no token is provided' do
166+
expect(@opts[:headers].key?('X-Authentication')).to eq false
167+
end
168+
169+
it 'should post the correct facts to HTTParty' do
170+
answer = JSON.parse(File.read(OctocatalogDiff::Spec.fixture_path('facts/facts_esc.json')))
171+
answer.delete('_timestamp')
172+
result = @parsed_data['facts']['values']
173+
expect(result).to eq(answer)
174+
end
175+
176+
it 'should set the environment in the parameters correctly for the API' do
177+
expect(@parsed_data['environment']).to eq('foobranch')
178+
end
179+
180+
it 'should default to false for persistence' do
181+
expect(@parsed_data['persistence']['facts']).to eq false
182+
expect(@parsed_data['persistence']['catalog']).to eq false
183+
end
184+
185+
it 'should parse the response and set instance variables correctly' do
186+
expect(@obj.catalog).to be_a_kind_of(Hash)
187+
expect(@obj.catalog_json).to be_a_kind_of(String)
188+
expect(@obj.error_message).to eq(nil)
189+
end
190+
191+
it 'should log correctly' do
192+
logs = @logger_str.string
193+
expect(logs).to match(/Start retrieving facts for foo from OctocatalogDiff::Catalog::PuppetMaster/)
194+
expect(logs).to match(%r{Retrieving facts from.*fixtures/facts/facts_esc.yaml})
195+
expect(logs).to match(%r{Retrieving facts from.*fixtures/facts/facts_esc.yaml})
196+
197+
answer = Regexp.new("Retrieve catalog from #{api_url[4]} environment foobranch")
198+
expect(logs).to match(answer)
199+
200+
answer2 = Regexp.new("Response from #{api_url[4]} environment foobranch was 200")
201+
expect(logs).to match(answer2)
202+
end
203+
204+
context 'when a RBAC token is passed' do
205+
let(:extra_opts) { { puppet_master_token: 'mytoken' } }
206+
207+
it 'should set the token in the headers' do
208+
expect(@opts[:headers]['X-Authentication']).to eq 'mytoken'
209+
end
210+
end
211+
212+
context 'when facts persistence is requested' do
213+
let(:extra_opts) { { puppet_master_update_facts: true } }
214+
215+
it 'should set the request in the parameters' do
216+
expect(@parsed_data['persistence']['facts']).to eq true
217+
end
218+
end
219+
220+
context 'when catalog persistence is requested' do
221+
let(:extra_opts) { { puppet_master_update_catalog: true } }
222+
223+
it 'should set the request in the parameters' do
224+
expect(@parsed_data['persistence']['catalog']).to eq true
225+
end
226+
end
227+
end
228+
139229
context 'response is not 200' do
140230
before(:each) do
141231
allow(OctocatalogDiff::Util::HTTParty).to receive(:post) do |_url, _opts, _post_data, _context|

0 commit comments

Comments
 (0)