Skip to content

Commit

Permalink
Add release property validation
Browse files Browse the repository at this point in the history
Releases can add a properties_schema.json file at the root of their jobs that is a json schema file.

This schema will be used to validate deployment properties prior to template rendering during a deploy.
  • Loading branch information
jpalermo committed Feb 5, 2025
1 parent d114816 commit 8729145
Show file tree
Hide file tree
Showing 19 changed files with 404 additions and 78 deletions.
8 changes: 8 additions & 0 deletions src/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ PATH
bosh_cpi
cf-uaa-lib
json
json_schemer
logging
membrane
nats-pure
Expand Down Expand Up @@ -165,6 +166,7 @@ GEM
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
hana (1.3.7)
hashdiff (1.1.2)
httpclient (2.8.3)
i18n (1.14.7)
Expand All @@ -173,6 +175,11 @@ GEM
io-event (1.7.5)
io-stream (0.6.1)
json (2.9.1)
json_schemer (2.4.0)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
simpleidn (~> 0.2)
language_server-protocol (3.17.0.4)
little-plugger (1.1.4)
logger (1.6.5)
Expand Down Expand Up @@ -284,6 +291,7 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1)
simplecov_json_formatter (0.1.4)
simpleidn (0.2.3)
sinatra (4.1.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
Expand Down
1 change: 1 addition & 0 deletions src/bosh-director/bosh-director.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Gem::Specification.new do |spec|
spec.add_dependency 'bosh_cpi'
spec.add_dependency 'cf-uaa-lib'
spec.add_dependency 'json'
spec.add_dependency 'json_schemer'
spec.add_dependency 'logging'
spec.add_dependency 'membrane'
spec.add_dependency 'nats-pure'
Expand Down
1 change: 1 addition & 0 deletions src/bosh-director/lib/bosh/director.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ module Director

require 'bosh/director/job_renderer'
require 'bosh/director/rendered_templates_persister'
require 'bosh/director/core/templates/job_schema_validator'

require 'bosh/director/audit_logger'
require 'bosh/director/cycle_helper'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ def render(spec_object)
RenderedJobInstance.new(rendered_templates)
end

def validate_properties!(spec_object)
@instance_jobs.each do |instance_job|
job_template_renderer = job_template_renderers[instance_job.name]
if job_template_renderer.properties_schema
JobSchemaValidator.validate(job_name: instance_job.name, schema: job_template_renderer.properties_schema, properties: spec_object['properties'][instance_job.name])
end
end
end

private

def job_template_renderers
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
require 'json_schemer'

module Bosh::Director::Core::Templates

class CustomType < JSONSchemer::Draft202012::Vocab::Validation::Type
def error(formatted_instance_location:, **)
case value
when 'certificate'
"value at #{formatted_instance_location} is not a certificate"
else
super
end
end

def valid_type(type, instance)
case type
when 'certificate'
return false unless instance.is_a?(String)
return true if instance == ""
begin
OpenSSL::X509::Certificate.load(instance)
rescue OpenSSL::X509::CertificateError => e
false
end
else
super
end
end
end

JSONSchemer::Draft202012::Vocab::VALIDATION['type'] = CustomType

class JobSchemaValidator
def self.validate(job_name:, schema:, properties:)
raise "You must declare your $schema draft version" if schema['$schema'].blank?
json_schemer_schema = JSONSchemer.schema(schema)
raise "Only https://json-schema.org/draft/2020-12/schema schema is currently supported" unless json_schemer_schema.meta_schema.base_uri == JSONSchemer::Draft202012::BASE_URI
errors = json_schemer_schema.validate(properties).map do |error|
error['error']
end
return true if errors.empty?
errors.unshift("Error validating properties for #{job_name}")
raise errors.join("\n")
end
end
end

Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ def process(instance_job)
monit_erb_file = File.read(File.join(template_dir, 'monit'))
monit_source_erb = SourceErb.new('monit', 'monit', monit_erb_file, instance_job.name)

if File.exist?(File.join(template_dir, 'properties_schema.json'))
properties_schema = JSON.load_file(File.join(template_dir, 'properties_schema.json'))
else
properties_schema = nil
end

source_erbs = []

instance_job.model.spec.fetch('templates', {}).each_pair do |src_filepath, dest_filepath|
Expand All @@ -49,6 +55,7 @@ def process(instance_job)
JobTemplateRenderer.new(instance_job: instance_job,
monit_erb: monit_source_erb,
source_erbs: source_erbs,
properties_schema: properties_schema,
logger: @logger,
link_provider_intents: @link_provider_intents,
dns_encoder: @dns_encoder)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,20 @@
module Bosh::Director::Core::Templates
class JobTemplateRenderer

attr_reader :monit_erb, :source_erbs
attr_reader :monit_erb, :source_erbs, :properties_schema

def initialize(instance_job:,
monit_erb:,
source_erbs:,
properties_schema:,
logger:,
link_provider_intents:,
dns_encoder: nil)
@links_provided = instance_job.model.provides
@job_name = instance_job.name
@release = instance_job.release
@monit_erb = monit_erb
@properties_schema = properties_schema
@source_erbs = source_erbs
@logger = logger
@link_provider_intents = link_provider_intents
Expand Down
4 changes: 3 additions & 1 deletion src/bosh-director/lib/bosh/director/job_renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ def self.render_job_instance(instance_plan, loader, logger)
logger.debug("Rendering templates for instance #{instance}")

instance_renderer = Core::Templates::JobInstanceRenderer.new(instance_jobs, loader)
rendered_job_instance = instance_renderer.render(get_templates_spec(instance_plan))
templates_spec = get_templates_spec(instance_plan)
instance_renderer.validate_properties!(templates_spec)
rendered_job_instance = instance_renderer.render(templates_spec)

instance_plan.rendered_templates = rendered_job_instance

Expand Down
1 change: 1 addition & 0 deletions src/bosh-director/spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ def database_logger

RSpec.configure do |config|
config.include(FactoryBot::Syntax::Methods)
config.include(CertificateHelpers)

config.around(:each) do |example|
SpecHelper.reset_database(SpecHelper.database, example)
Expand Down
44 changes: 44 additions & 0 deletions src/bosh-director/spec/support/certificate_helpers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
module CertificateHelpers
class KeyCache
@key = nil
class << self
attr_accessor :key
end
end

def generate_rsa_certificate(sans: [])
rsa_private_key = KeyCache.key
if !rsa_private_key
rsa_private_key = OpenSSL::PKey::RSA.generate(512) #Smallest key OpenSSL will currently allow to not waste time on cpu cycles
KeyCache.key = rsa_private_key
end
rsa_cert = generate_cert(rsa_private_key, sans: sans)
private_key_cipher = OpenSSL::Cipher.new 'aes-256-cbc'
{
:cert_pem => rsa_cert.to_pem,
:public_key_pem => rsa_private_key.public_to_pem,
:private_key_pem => rsa_private_key.to_pem,
}
end

def generate_cert(key, sans: [])
cert = OpenSSL::X509::Certificate.new
cert.version = 2 # cf. RFC 5280 - to make it a "v3" certificate
cert.serial = 1 # Not secure, but this is a test certificate
cert.subject = OpenSSL::X509::Name.parse "/CN=Test CA"
cert.issuer = cert.subject # root CA's are "self-signed"
cert.public_key = key
cert.not_before = 5.minutes.ago
cert.not_after = cert.not_before + (2 * 7 * 24 * 60 * 60) # 2 weeks validity
ef = OpenSSL::X509::ExtensionFactory.new
ef.subject_certificate = cert
ef.issuer_certificate = cert
cert.add_extension(ef.create_extension("basicConstraints","CA:TRUE",true))
cert.add_extension(ef.create_extension("keyUsage","keyCertSign, cRLSign", true))
cert.add_extension(ef.create_extension("subjectKeyIdentifier","hash",false))
cert.add_extension(ef.create_extension("authorityKeyIdentifier","keyid:always",false))
cert.add_extension(ef.create_extension('subjectAltName', sans.join(','))) unless sans.empty?
cert.sign(key, OpenSSL::Digest.new('SHA256'))
cert
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,28 @@

module Bosh::Director::Core::Templates
describe JobInstanceRenderer do
describe '#render' do
subject(:job_instance_renderer) { JobInstanceRenderer.new(templates, job_template_loader) }
subject(:job_instance_renderer) { JobInstanceRenderer.new(jobs, job_template_loader) }
let(:job_template_loader) { instance_double('Bosh::Director::Core::Templates::JobTemplateLoader') }
let(:job_template_renderer) { instance_double('Bosh::Director::Core::Templates::JobTemplateRenderer') }
let(:instance_group_name) { 'fake-instance-group-name' }
let(:properties) { {} }
let(:spec) do
{
'name' => instance_group_name,
'job' => { # <- here 'job' is the Bosh v1 term for 'instance group'
'name' => instance_group_name
},
'properties' => properties
}
end

let(:spec) do
{
'name' => 'fake-instance-group-name',
'job' => { # <- here 'job' is the Bosh v1 term for 'instance group'
'name' => 'fake-instance-group-name'
}
}
end
let(:job_template_loader) { instance_double('Bosh::Director::Core::Templates::JobTemplateLoader') }
let(:job_template_renderer) { instance_double('Bosh::Director::Core::Templates::JobTemplateRenderer') }
describe '#render' do
let(:expected_rendered_job_instance) { instance_double('Bosh::Director::Core::Templates::RenderedJobInstance') }

before { allow(RenderedJobInstance).to receive(:new).and_return(expected_rendered_job_instance) }
let(:expected_rendered_job_instance) { instance_double('Bosh::Director::Core::Templates::RenderedJobInstance') }

context 'when job has no templates' do
let(:templates) { [] }
let(:jobs) { [] }

it 'returns empty array' do

Expand All @@ -34,23 +37,23 @@ module Bosh::Director::Core::Templates
end

context 'when job has one job_template' do
let(:templates) { [double('template', name: 'a')] }
let(:jobs) { [double('template', name: 'a')] }
let(:expected_rendered_templates) { [double('rendered template')] }

before do
allow(job_template_renderer).to receive(:render).with(spec).and_return(expected_rendered_templates[0])
end

it 'returns the rendered template for the given instance' do
allow(job_template_loader).to receive(:process).with(templates[0]).and_return(job_template_renderer)
allow(job_template_loader).to receive(:process).with(jobs[0]).and_return(job_template_renderer)

job_instance_renderer.render(spec)
expect(RenderedJobInstance).to have_received(:new).with(expected_rendered_templates)
end

context 'when called for multiple instances' do
it 'only processes the source job templates once' do
expect(job_template_loader).to receive(:process).with(templates[0]).and_return(job_template_renderer)
expect(job_template_loader).to receive(:process).with(jobs[0]).and_return(job_template_renderer)

job_instance_renderer.render(spec)
job_instance_renderer.render(spec)
Expand All @@ -59,7 +62,7 @@ module Bosh::Director::Core::Templates
end

context 'when job has multiple job_templates' do
let(:templates) { [double('template1', name: 'b'), double('template2', name: 'a')] }
let(:jobs) { [double('template1', name: 'b'), double('template2', name: 'a')] }
let(:expected_rendered_templates) do
[
double('rendered job template1'),
Expand All @@ -69,8 +72,8 @@ module Bosh::Director::Core::Templates
let(:job_template_renderer2) { instance_double('Bosh::Director::Core::Templates::JobTemplateRenderer') }

before do
allow(job_template_loader).to receive(:process).with(templates[0]).and_return(job_template_renderer)
allow(job_template_loader).to receive(:process).with(templates[1]).and_return(job_template_renderer2)
allow(job_template_loader).to receive(:process).with(jobs[0]).and_return(job_template_renderer)
allow(job_template_loader).to receive(:process).with(jobs[1]).and_return(job_template_renderer2)

allow(job_template_renderer).to receive(:render).with(spec).and_return(expected_rendered_templates[0])
allow(job_template_renderer2).to receive(:render).with(spec).and_return(expected_rendered_templates[1])
Expand Down Expand Up @@ -123,5 +126,40 @@ module Bosh::Director::Core::Templates
end
end
end

describe 'validate_properties!' do
let(:job_name) { 'fake-job-name' }
let(:job) { instance_double('Bosh::Director::DeploymentPlan::Job', name: job_name) }
let(:jobs) { [job] }
let(:properties) do
{
job_name => {
'a_property' => 'a_value'
}
}
end

before do
allow(job_template_loader).to receive(:process).with(job).and_return(job_template_renderer)
allow(job_template_renderer).to receive(:properties_schema).and_return(properties_schema)
end

context 'when the job has a json schema' do
let(:properties_schema) { { "schema": true} }

it 'calls the schema verifier' do
expect(JobSchemaValidator).to receive(:validate).with(job_name: job_name, schema: properties_schema, properties: properties[job_name])
job_instance_renderer.validate_properties!(spec)
end
end

context 'when the job does not have a json schema' do
let(:properties_schema) { nil }

it 'does not error' do
expect { job_instance_renderer.validate_properties!(spec) }.not_to raise_error
end
end
end
end
end
Loading

0 comments on commit 8729145

Please sign in to comment.