Skip to content

Commit da8091d

Browse files
mvicknrclaude
andcommitted
chore: Add schema bump and documentation
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e186b90 commit da8091d

8 files changed

Lines changed: 681 additions & 17 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env ruby
2+
# This file is distributed under New Relic's license terms.
3+
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
4+
# frozen_string_literal: true
5+
6+
require 'json'
7+
require_relative 'schema_diff'
8+
9+
# Driver for the config schema version bump: diffs the current schema against
10+
# the previous release's schema (read from the latest release tag) and, on
11+
# --write, updates the version in configurationDefinitions.yml. The change
12+
# classification lives in SchemaDiff; this file is the git + file-I/O glue.
13+
#
14+
# Run it: `ruby bump_schema_version.rb` # dry-run (reports)
15+
# `ruby bump_schema_version.rb --write` # apply the bump
16+
module BumpSchemaVersion
17+
FLEET_CONTROL_DIR = File.expand_path('..', __dir__)
18+
SCHEMA_PATH = File.join(FLEET_CONTROL_DIR, 'schemas', 'config.json')
19+
CONFIG_DEF_PATH = File.join(FLEET_CONTROL_DIR, 'configurationDefinitions.yml')
20+
SCHEMA_REPO_PATH = '.fleetControl/schemas/config.json'
21+
CONFIG_DEF_REPO_PATH = '.fleetControl/configurationDefinitions.yml'
22+
23+
module_function
24+
25+
# Full run: read the previous release from git, decide, write, and report.
26+
def run(write: false, schema_path: SCHEMA_PATH, config_def_path: CONFIG_DEF_PATH)
27+
tag, baseline, starter_version = previous_release
28+
current = SchemaDiff.load_existing(schema_path)
29+
result = apply(baseline: baseline, current: current, starter_version: starter_version,
30+
config_def_path: config_def_path, write: write)
31+
report(result, tag, write)
32+
result
33+
end
34+
35+
# Decide (via SchemaDiff) and, when write: true and a bump is due, rewrite the
36+
# version line. Pure of git, so it can be exercised with fixtures. Returns the
37+
# SchemaDiff.bump result Hash.
38+
def apply(baseline:, current:, starter_version:, config_def_path:, write:)
39+
result = SchemaDiff.bump(baseline: baseline, current: current, starter_version: starter_version)
40+
return result unless write && result[:action] == :bump
41+
42+
text = File.read(config_def_path)
43+
if text[/^\s*version:\s*(\S+)/, 1] != result[:new_version]
44+
File.write(config_def_path, SchemaDiff.bump_version_line(text, result[:new_version]))
45+
end
46+
result
47+
end
48+
49+
# [tag, baseline_schema_or_nil, starter_version_or_nil] for the latest release.
50+
def previous_release
51+
tag = latest_release_tag
52+
return [nil, nil, nil] unless tag
53+
54+
schema_text = git_show(tag, SCHEMA_REPO_PATH)
55+
defs_text = git_show(tag, CONFIG_DEF_REPO_PATH)
56+
baseline = schema_text ? JSON.parse(schema_text) : nil
57+
[tag, baseline, defs_text && defs_text[/^\s*version:\s*(\S+)/, 1]]
58+
end
59+
60+
# Latest final release tag (bare X.Y.Z, ignoring -pre prereleases), or nil.
61+
def latest_release_tag
62+
`git tag --list --sort=-v:refname`.split("\n").map(&:strip).find { |name| name.match?(/\A\d+\.\d+\.\d+\z/) }
63+
end
64+
65+
def git_show(ref, path)
66+
out = `git show #{ref}:#{path} 2>/dev/null`
67+
out.empty? ? nil : out
68+
end
69+
70+
def report(result, tag, write)
71+
(result[:changes] || []).each { |c| puts " #{SchemaDiff.render_change(c)}" }
72+
73+
case result[:action]
74+
when :first_release
75+
puts 'No schema at the last release (or no release tag); treating this as the first. No bump.'
76+
when :no_change
77+
puts "Schema unchanged since #{tag}; no version bump."
78+
when :bump
79+
puts(if write
80+
"Bumped schema version: #{result[:old_version]} -> #{result[:new_version]}."
81+
else
82+
"Recommended bump: #{result[:bump]} (#{result[:old_version]} -> #{result[:new_version]}). Dry-run; pass --write to apply."
83+
end)
84+
end
85+
end
86+
end
87+
88+
if __FILE__ == $PROGRAM_NAME
89+
begin
90+
BumpSchemaVersion.run(write: ARGV.include?('--write'))
91+
rescue StandardError => e
92+
warn "Schema bump failed: #{e.class}: #{e.message}"
93+
exit 2
94+
end
95+
end

.fleetControl/schemaGeneration/generate_schema.rb

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
require 'fileutils'
88
# Optional: used to validate the generated schema against the Draft 2020-12
99
# meta-schema before writing. Absent on the old-Ruby CI job (gated out of the
10-
# gemspec), where validation soft-skips rather than failing — mirrors the Java
11-
# generator's behavior when its validator can't load.
10+
# gemspec), where validation soft-skips rather than failing.
1211
begin
1312
require 'json_schemer'
1413
rescue LoadError
@@ -21,8 +20,8 @@
2120

2221
# Generates a JSON Schema (Draft 2020-12) describing the New Relic Ruby agent's
2322
# configuration, read straight from NewRelic::Agent::Configuration::DEFAULTS.
24-
# This is the Ruby parallel to the Java agent's GenerateSchema.groovy, and feeds
25-
# the same Fleet Control consumption path (../schemas/config.json).
23+
# The output (../schemas/config.json) is what Fleet Control consumes to
24+
# validate and render agent configuration.
2625
#
2726
# Run it: `ruby .fleetControl/schemaGeneration/generate_schema.rb`
2827
module GenerateSchema
@@ -46,9 +45,9 @@ module GenerateSchema
4645
}.freeze
4746

4847
# Enum values for settings that are effectively enums but carry no :allowlist
49-
# in DEFAULTS. The Java agent keeps an equivalent ENUM_OVERRIDES map. Values
50-
# are verified against the code that consumes each setting, NOT the prose
51-
# descriptions (which omit log_level's `fatal` and mislabel `to_app`):
48+
# in DEFAULTS. Values are verified against the code that consumes each setting,
49+
# NOT the prose descriptions (which omit log_level's `fatal` and mislabel
50+
# `to_app`):
5251
# - log_level: agent_logger.rb LOG_LEVELS
5352
# - record_sql: database.rb#record_sql_method
5453
#
@@ -93,11 +92,11 @@ def generate(defaults = DEFAULTS)
9392
'description' => 'Configuration settings for the New Relic Ruby agent. ' \
9493
'Generated from DEFAULTS in lib/new_relic/agent/configuration/default_source.rb.',
9594
'type' => 'object',
96-
# true (not false), matching the Java agent: the agent ships new config
97-
# keys every release, and a Fleet Control deployment may validate against
98-
# a schema generated from an older agent. Strict validation would reject
99-
# any newer key and break users who upgrade before the schema is
100-
# republished. Flip to false only alongside a lockstep republish process.
95+
# true (not false) on purpose: the agent ships new config keys every
96+
# release, and a Fleet Control deployment may validate against a schema
97+
# generated from an older agent. Strict validation would reject any newer
98+
# key and break users who upgrade before the schema is republished. Flip
99+
# to false only alongside a lockstep republish process.
101100
'additionalProperties' => true,
102101
'properties' => properties
103102
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#!/usr/bin/env ruby
2+
# This file is distributed under New Relic's license terms.
3+
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
4+
# frozen_string_literal: true
5+
6+
require 'json'
7+
8+
# Classifies the difference between two generated config schemas and turns that
9+
# into a semantic-version bump for the Fleet Control config schema.
10+
#
11+
# The schema uses flat dotted keys with no `required` array and a fixed
12+
# `additionalProperties: true` at the root, so only the change kinds that shape
13+
# can actually produce are classified. If the generator ever starts emitting a
14+
# `required` array, nested objects, or `additionalProperties: false`, add the
15+
# matching rules here — otherwise those changes would be silently under-bumped.
16+
module SchemaDiff
17+
# Change kinds grouped by the bump they force. Most severe wins.
18+
BREAKING = %w[property_removed type_changed enum_introduced enum_value_removed].freeze
19+
ADDITIVE = %w[property_added enum_removed_entirely enum_value_added default_changed].freeze
20+
COSMETIC = %w[description_changed].freeze
21+
22+
NO_DEFAULT = Object.new # sentinel so an absent default is distinct from a nil default
23+
24+
module_function
25+
26+
# Parse a schema JSON file into a Hash. Missing or malformed files yield {}
27+
# so a bootstrap run (no baseline schema yet) is handled by the caller.
28+
def load_existing(path)
29+
return {} unless File.exist?(path)
30+
31+
JSON.parse(File.read(path))
32+
rescue JSON::ParserError
33+
{}
34+
end
35+
36+
# Decide the version bump from the previous release's schema. `baseline` is
37+
# the previous release's parsed schema (nil/empty means there was no schema at
38+
# the last release — treat this as the first one, no bump). `starter_version`
39+
# is the schema version at that release, which the bump is applied to (so a
40+
# re-run against the same release is idempotent). Returns
41+
# { action:, bump:, old_version:, new_version:, changes: }.
42+
#
43+
# Actions: :first_release (no prior schema), :no_change (unchanged since the
44+
# last release), :bump (a version bump is recommended).
45+
def bump(baseline:, current:, starter_version:)
46+
# No prior schema, or no version to bump from -> treat as the first release.
47+
return {action: :first_release, bump: 'none', changes: []} if baseline.nil? || baseline.empty? || starter_version.to_s.empty?
48+
49+
changes = classify_changes(baseline, current)
50+
kind = recommend_bump(changes)
51+
return {action: :no_change, bump: 'none', changes: changes} if kind == 'none'
52+
53+
{action: :bump, bump: kind, old_version: starter_version,
54+
new_version: apply_bump(starter_version, kind), changes: changes}
55+
end
56+
57+
# Compare two schemas' property maps and return a list of change records:
58+
# { path:, kind:, severity:, detail: }.
59+
def classify_changes(old_schema, new_schema)
60+
old_props = old_schema['properties'] || {}
61+
new_props = new_schema['properties'] || {}
62+
changes = []
63+
64+
(old_props.keys | new_props.keys).sort.each do |key|
65+
if !old_props.key?(key)
66+
changes << change(key, 'property_added', 'new property')
67+
elsif !new_props.key?(key)
68+
changes << change(key, 'property_removed', 'property removed')
69+
else
70+
changes.concat(classify_leaf(old_props[key], new_props[key], key))
71+
end
72+
end
73+
74+
changes
75+
end
76+
77+
# Classify the changes within a single property (type, enum, default,
78+
# description). Returns an array; a property can change in more than one way.
79+
def classify_leaf(old_prop, new_prop, path)
80+
changes = []
81+
changes.concat(type_changes(old_prop, new_prop, path))
82+
changes.concat(enum_changes(old_prop, new_prop, path))
83+
changes.concat(default_changes(old_prop, new_prop, path))
84+
changes.concat(description_changes(old_prop, new_prop, path))
85+
changes
86+
end
87+
88+
# Highest-severity bump implied by the changes.
89+
def recommend_bump(changes)
90+
severities = changes.map { |c| c[:severity] }
91+
return 'major' if severities.include?('breaking')
92+
return 'minor' if severities.include?('additive')
93+
return 'patch' if severities.include?('cosmetic')
94+
95+
'none'
96+
end
97+
98+
# Apply a bump kind to a semver string (X.Y.Z). 'none' returns it unchanged.
99+
def apply_bump(version, bump)
100+
return version if bump == 'none'
101+
102+
major, minor, patch = parse_semver(version)
103+
case bump
104+
when 'major' then "#{major + 1}.0.0"
105+
when 'minor' then "#{major}.#{minor + 1}.0"
106+
when 'patch' then "#{major}.#{minor}.#{patch + 1}"
107+
else raise ArgumentError, "unknown bump kind: #{bump.inspect}"
108+
end
109+
end
110+
111+
# Replace the single `version:` line in configurationDefinitions.yml text.
112+
# Raises unless exactly one such line exists, so a malformed file can't be
113+
# silently half-updated.
114+
def bump_version_line(yaml_text, new_version)
115+
pattern = /^(\s*version:\s*)(\S+)(\s*)$/
116+
matches = yaml_text.scan(pattern).size
117+
raise "expected exactly 1 'version:' line, found #{matches}" unless matches == 1
118+
119+
yaml_text.sub(pattern) { "#{Regexp.last_match(1)}#{new_version}#{Regexp.last_match(3)}" }
120+
end
121+
122+
# One-line human rendering: + added, - removed, ~ modified.
123+
def render_change(change)
124+
symbol = case change[:kind]
125+
when 'property_added' then '+'
126+
when 'property_removed' then '-'
127+
else '~'
128+
end
129+
130+
"#{symbol} #{change[:path]}: #{change[:detail]}"
131+
end
132+
133+
# --- internals --------------------------------------------------------------
134+
135+
def change(path, kind, detail)
136+
{path: path, kind: kind, severity: severity_for(kind), detail: detail}
137+
end
138+
139+
def severity_for(kind)
140+
return 'breaking' if BREAKING.include?(kind)
141+
return 'additive' if ADDITIVE.include?(kind)
142+
return 'cosmetic' if COSMETIC.include?(kind)
143+
144+
raise ArgumentError, "unclassified change kind: #{kind.inspect}"
145+
end
146+
147+
# The type aspect of a property: its `anyOf` shape if present, else its `type`.
148+
def type_signature(prop)
149+
prop['anyOf'] || prop['type']
150+
end
151+
152+
def type_changes(old_prop, new_prop, path)
153+
old_sig = type_signature(old_prop)
154+
new_sig = type_signature(new_prop)
155+
return [] if old_sig == new_sig
156+
157+
[change(path, 'type_changed', "#{describe_type(old_sig)} -> #{describe_type(new_sig)}")]
158+
end
159+
160+
def describe_type(signature)
161+
signature.is_a?(String) ? signature : 'array-or-string'
162+
end
163+
164+
def enum_changes(old_prop, new_prop, path)
165+
old_enum = old_prop['enum']
166+
new_enum = new_prop['enum']
167+
return [] if old_enum == new_enum
168+
return [change(path, 'enum_introduced', "enum added: #{new_enum.join(', ')}")] if old_enum.nil?
169+
return [change(path, 'enum_removed_entirely', 'enum constraint removed')] if new_enum.nil?
170+
171+
changes = []
172+
removed = old_enum - new_enum
173+
added = new_enum - old_enum
174+
changes << change(path, 'enum_value_removed', "enum values removed: #{removed.join(', ')}") unless removed.empty?
175+
changes << change(path, 'enum_value_added', "enum values added: #{added.join(', ')}") unless added.empty?
176+
changes
177+
end
178+
179+
def default_changes(old_prop, new_prop, path)
180+
old_default = old_prop.key?('default') ? old_prop['default'] : NO_DEFAULT
181+
new_default = new_prop.key?('default') ? new_prop['default'] : NO_DEFAULT
182+
return [] if old_default == new_default
183+
184+
[change(path, 'default_changed', "default changed: #{old_default.inspect} -> #{new_default.inspect}")]
185+
end
186+
187+
def description_changes(old_prop, new_prop, path)
188+
return [] if old_prop['description'] == new_prop['description']
189+
190+
[change(path, 'description_changed', 'description changed')]
191+
end
192+
193+
def parse_semver(version)
194+
match = /\A(\d+)\.(\d+)\.(\d+)\z/.match(version.to_s)
195+
raise ArgumentError, "not a semver version: #{version.inspect}" unless match
196+
197+
match.captures.map(&:to_i)
198+
end
199+
end

0 commit comments

Comments
 (0)