diff --git a/.fleetControl/configurationDefinitions.yml b/.fleetControl/configurationDefinitions.yml index 2c4ea16e34..b82112290e 100644 --- a/.fleetControl/configurationDefinitions.yml +++ b/.fleetControl/configurationDefinitions.yml @@ -17,4 +17,5 @@ configurationDefinitions: description: Python agent configuration type: agent-config version: 1.0.0 -# will add schema information here later + schema: ./schemas/config.json + format: ini diff --git a/.fleetControl/schemaGeneration/generate-schema.py b/.fleetControl/schemaGeneration/generate-schema.py new file mode 100644 index 0000000000..5f0a1d65f2 --- /dev/null +++ b/.fleetControl/schemaGeneration/generate-schema.py @@ -0,0 +1,675 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Fleet Control Config Schema Generator -- Python Agent + +This script was initially generated by Claude. + +Reads newrelic.ini from the working tree and writes JSON Schema Draft +2020-12 to .fleetControl/schemas/config.json. + +Source resolution: + 1. NEWRELIC_INI env var, if set + 2. /newrelic/newrelic.ini + (resolved relative to this script's location) + +Merge behavior: + The generator never starts fresh -- each run deep-merges the freshly + generated schema into whatever already exists at config.json. Properties + are union'd (keys present only in the old schema are preserved); leaf + nodes and the top-level `required` list take the new run's values. This + guarantees the published schema only ever grows, so a config that + validated against an older agent's schema continues to validate against + the current one. + +Exit codes: + 0 -- no schema changes (or first run) + 1 -- schema changed (CI should commit the updated files) + 2 -- hard failure (invalid schema, missing input, malformed + configurationDefinitions.yml) + +Run standalone: + python generate-schema.py + +Override the source file: + NEWRELIC_INI=/path/to/newrelic.ini python generate-schema.py + +--------------------------------------------------------------------------- +Why the schema emits `additionalProperties: true` +--------------------------------------------------------------------------- +The generator sets `additionalProperties: true` at the root. This is +intentional and serves two purposes: + + 1. Forward compatibility. The agent ships new config keys in every + release. A Fleet Control deployment may be validating against a + schema generated from an older newrelic.ini -- strict validation + would reject any newer key, breaking users who upgrade the agent + before the schema is republished. + + 2. Coverage gaps. Some keys are deliberately excluded (see EXCLUDE_KEYS) + and some shapes the generator can't represent faithfully. Permitting + unknown properties means a config that uses those still validates + instead of being flagged as malformed. + +If a future requirement calls for strict validation (catch typos, reject +unknown keys), flip this to `false` -- but doing so should be paired with +a release process that republishes the schema in lockstep with the agent. +""" + +import argparse +import json +import os +import re +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Paths -- all resolved relative to this script. Script lives at +# /.fleetControl/schemaGeneration/ so the repo root is two +# levels up. +# --------------------------------------------------------------------------- +SCRIPT_DIR = Path(__file__).resolve().parent +FLEET_CONTROL_DIR = SCRIPT_DIR.parent +REPO_ROOT = FLEET_CONTROL_DIR.parent +SCHEMA_DIR = FLEET_CONTROL_DIR / "schemas" +SCHEMA_PATH = SCHEMA_DIR / "config.json" +CONFIG_DEF_PATH = FLEET_CONTROL_DIR / "configurationDefinitions.yml" +DEFAULT_INI_PATH = REPO_ROOT / "newrelic" / "newrelic.ini" + +# --------------------------------------------------------------------------- +# Enum / special-value overrides. Keys are dotted INI keys exactly as they +# appear in the schema's `properties` map. +# --------------------------------------------------------------------------- +ENUM_OVERRIDES = { + "log_level": ["critical", "error", "warning", "info", "debug"], + "transaction_tracer.record_sql": ["off", "raw", "obfuscated"], +} + +# --------------------------------------------------------------------------- +# Type overrides -- when the INI default doesn't reflect the documented +# semantic. INI values are always strings, so settings that are really +# lists (space- or comma-separated) need explicit array typing. +# --------------------------------------------------------------------------- +TYPE_OVERRIDES = { + "error_collector.ignore_classes": {"type": "array", "items": {"type": "string"}, "default": []}, + "error_collector.expected_classes": {"type": "array", "items": {"type": "string"}, "default": []}, + "transaction_tracer.function_trace":{"type": "array", "items": {"type": "string"}, "default": []}, + "attributes.include": {"type": "array", "items": {"type": "string"}, "default": []}, + "attributes.exclude": {"type": "array", "items": {"type": "string"}, "default": []}, +} + +# --------------------------------------------------------------------------- +# Keys to exclude from the generated schema. +# +# Use this for settings the Fleet Control UI shouldn't surface -- typically +# because they are derived at runtime, structurally awkward to represent in +# JSON Schema, or only relevant for internal/debug scenarios. +# +# Add a key's exact dotted form (matching the property name in the schema). +# --------------------------------------------------------------------------- +EXCLUDE_KEYS = set() + + +# --------------------------------------------------------------------------- +# Type inference -- INI values are always strings; we infer the intended +# JSON Schema type from the string content. +# --------------------------------------------------------------------------- + +_INT_RE = re.compile(r"^-?\d+$") +_FLOAT_RE = re.compile(r"^-?\d+\.\d+$") + + +def infer_type(value): + """Infer a JSON Schema type from a raw INI string value. + + Returns one of: 'boolean', 'integer', 'number', 'string'. Empty strings + return 'string' so the caller can decide whether to omit the default. + """ + if value is None: + return "string" + s = value.strip() + if s.lower() in ("true", "false"): + return "boolean" + if _INT_RE.match(s): + return "integer" + if _FLOAT_RE.match(s): + return "number" + return "string" + + +def coerce_default(value, json_type): + """Convert a raw INI string to a Python value matching `json_type`.""" + s = value.strip() + if json_type == "boolean": + return s.lower() == "true" + if json_type == "integer": + return int(s) + if json_type == "number": + return float(s) + return value # preserve original string formatting + + +def make_property(key, raw_value, description, enum_overrides, type_overrides): + """Build a JSON Schema property node for a single INI key. + + `raw_value` may be None (key was commented-out) or a string. Descriptions + are trimmed; empty descriptions are omitted. + """ + if key in type_overrides: + prop = dict(type_overrides[key]) + elif key in enum_overrides: + enum_vals = enum_overrides[key] + prop = {"type": "string", "enum": list(enum_vals)} + if raw_value is not None and raw_value.strip() in enum_vals: + prop["default"] = raw_value.strip() + else: + json_type = infer_type(raw_value) + prop = {"type": json_type} + if raw_value is not None and raw_value != "": + prop["default"] = coerce_default(raw_value, json_type) + + if description: + prop["description"] = description.strip() + return prop + + +# --------------------------------------------------------------------------- +# INI parsing -- pulls keys, raw values, and contiguous comment blocks +# attached to each key. Only the [newrelic] section is processed. +# +# We scan line-by-line rather than using configparser because: +# 1. configparser strips comments, so we'd lose the descriptions. +# 2. INI keys in this file include literal dots ('transaction_tracer.enabled'), +# which configparser handles fine -- but we still need the comment scan. +# 3. We need to honor commented-out keys (e.g. '# proxy_host = hostname') +# as descriptions for the *next* live key, NOT bleed onto it. A blank +# line between resets the pending block; configparser would just skip. +# --------------------------------------------------------------------------- + +# Section header: [newrelic] or [newrelic:production], etc. +_SECTION_RE = re.compile(r"^\s*\[([^\]]+)\]\s*$") +# Live key=value line. Keys may contain dots and underscores. +_KEY_RE = re.compile(r"^([a-zA-Z_][\w.\-]*)\s*=\s*(.*)$") + + +def parse_ini(text, section="newrelic"): + """Parse `text` and return (keys, comments) for the named section. + + `keys` is an OrderedDict-style {key: raw_value_string_or_None} preserving + insertion order. `comments` is {key: joined_comment_string}. Only commented- + out keys whose name matches another live key are dropped (they served as + description anchors); the rest are ignored. + + A blank line clears any pending comment block, so commented-out config + examples do not bleed into the next live key's description. + """ + keys = {} + comments = {} + pending = [] + current_section = None + + for raw_line in text.splitlines(): + line = raw_line.rstrip("\r") + stripped = line.strip() + + if stripped == "": + pending = [] + continue + + m = _SECTION_RE.match(line) + if m: + current_section = m.group(1).strip() + pending = [] + continue + + if line.lstrip().startswith("#"): + # Strip leading '#' and one optional space. Preserve the rest. + content = line.lstrip()[1:].removeprefix(" ") + pending.append(content.rstrip()) + continue + + if current_section != section: + pending = [] + continue + + km = _KEY_RE.match(line) + if km: + key = km.group(1) + value = km.group(2).strip() + keys[key] = value + if pending: + comments[key] = " ".join(p.strip() for p in pending if p.strip()) + pending = [] + else: + pending = [] + + return keys, comments + + +# --------------------------------------------------------------------------- +# Schema generation +# --------------------------------------------------------------------------- + +LICENSE_KEY_OVERRIDE = { + "type": "string", + "description": ( + "New Relic license key associated with your account. " + "Binds the agent's data to your account in the New Relic UI." + ), + "minLength": 1, +} + + +def build_properties(keys, comments, exclude_keys, enum_overrides, type_overrides): + """Build the JSON Schema `properties` map from parsed INI keys.""" + properties = {} + for key, raw_value in keys.items(): + if key in exclude_keys: + continue + desc = comments.get(key, "") + properties[key] = make_property( + key, raw_value, desc, enum_overrides, type_overrides + ) + return properties + + +def generate_schema(ini_text, exclude_keys=None, enum_overrides=None, type_overrides=None): + """Generate a JSON Schema dict from raw newrelic.ini text.""" + if exclude_keys is None: + exclude_keys = EXCLUDE_KEYS + if enum_overrides is None: + enum_overrides = ENUM_OVERRIDES + if type_overrides is None: + type_overrides = TYPE_OVERRIDES + + keys, comments = parse_ini(ini_text, section="newrelic") + properties = build_properties( + keys, comments, exclude_keys, enum_overrides, type_overrides + ) + + if "license_key" in properties: + properties["license_key"] = dict(LICENSE_KEY_OVERRIDE) + + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "New Relic Python Agent Configuration", + "description": ( + "Fleet Control configuration schema for the New Relic Python agent. " + "Generated from newrelic/newrelic.ini." + ), + "type": "object", + "properties": properties, + "required": ["license_key", "app_name"], + "additionalProperties": True, + } + + +# --------------------------------------------------------------------------- +# Schema merge -- deep-merges a freshly generated schema into the existing +# one so the published schema only ever grows. Behavior: +# +# * `properties` maps are union'd by key. Keys only in the old schema are +# preserved verbatim; keys only in the new schema are added; keys in both +# recurse if both sides are object nodes, otherwise the new leaf wins. +# * Top-level scalars and arrays (`title`, `description`, `required`, +# `additionalProperties`, etc.) take the new run's value. +# +# Why old leaves don't survive a type change: if a key in newrelic.ini moves +# from `enabled = true` to `count = 5`, the schema must reflect the current +# shape so validation matches reality. Only the *key* is preserved across +# runs, not the constraint that no longer applies. +# --------------------------------------------------------------------------- + +def merge_schemas(old_s, new_s): + if not old_s: + return new_s + + merged = dict(new_s) + + old_props = old_s.get("properties") or {} + new_props = new_s.get("properties") or {} + if old_props or new_props: + merged["properties"] = merge_properties(old_props, new_props) + + return merged + + +def merge_properties(old_props, new_props): + result = {} + # Walk new first so keys in both retain new ordering. + for key, new_val in new_props.items(): + if ( + key in old_props + and isinstance(new_val, dict) + and isinstance(old_props[key], dict) + and new_val.get("type") == "object" + and old_props[key].get("type") == "object" + ): + result[key] = merge_schemas(old_props[key], new_val) + else: + result[key] = new_val + # Carry forward keys only in old. + for key, old_val in old_props.items(): + if key not in result: + result[key] = old_val + return result + + +# --------------------------------------------------------------------------- +# Schema diff classification -- distinguishes breaking from additive changes. +# Change records are plain dicts: +# {path: str, kind: str, severity: 'breaking'|'additive'|'cosmetic', +# detail: str} +# --------------------------------------------------------------------------- + +def render_change(c): + kind = c["kind"] + sym = "+" if kind == "added" else ("-" if kind == "removed" else "~") + detail = c.get("detail") or "" + path = c["path"] + return f"{sym} {path}: {detail}" if detail else f"{sym} {path}" + + +def classify_changes(old_s, new_s, path=""): + changes = [] + + old_req = set(old_s.get("required") or []) + new_req = set(new_s.get("required") or []) + changes.extend( + { + "path": f"{path}.{k}" if path else k, + "kind": "required_added", + "severity": "breaking", + "detail": "now required", + } + for k in sorted(new_req - old_req) + ) + changes.extend( + { + "path": f"{path}.{k}" if path else k, + "kind": "required_removed", + "severity": "additive", + "detail": "no longer required", + } + for k in sorted(old_req - new_req) + ) + + old_ap = old_s.get("additionalProperties", True) + new_ap = new_s.get("additionalProperties", True) + if old_ap is True and new_ap is False: + changes.append({ + "path": path or "", + "kind": "additional_properties_tightened", + "severity": "breaking", + "detail": "additionalProperties: true -> false", + }) + elif old_ap is False and new_ap is True: + changes.append({ + "path": path or "", + "kind": "additional_properties_loosened", + "severity": "additive", + "detail": "additionalProperties: false -> true", + }) + + old_props = old_s.get("properties") or {} + new_props = new_s.get("properties") or {} + for key in sorted(set(old_props.keys()) | set(new_props.keys())): + child_path = f"{path}.{key}" if path else key + if key not in old_props: + changes.append({ + "path": child_path, "kind": "added", + "severity": "additive", "detail": "new property", + }) + elif key not in new_props: + changes.append({ + "path": child_path, "kind": "removed", + "severity": "breaking", "detail": "property removed", + }) + else: + op = old_props[key] + np = new_props[key] + if op.get("type") == "object" and np.get("type") == "object": + changes.extend(classify_changes(op, np, child_path)) + else: + changes.extend(classify_leaf(op, np, child_path)) + return changes + + +def classify_leaf(op, np, path): + changes = [] + + if op.get("type") != np.get("type"): + changes.append({ + "path": path, "kind": "type_changed", "severity": "breaking", + "detail": f"type {op.get('type')} -> {np.get('type')}", + }) + + oe = op.get("enum") + ne = np.get("enum") + if oe is None and ne is not None: + changes.append({ + "path": path, "kind": "enum_introduced", "severity": "breaking", + "detail": f"newly constrained to enum {ne}", + }) + elif oe is not None and ne is None: + changes.append({ + "path": path, "kind": "enum_removed_entirely", "severity": "additive", + "detail": "enum constraint removed", + }) + elif oe and ne and set(oe) != set(ne): + changes.extend( + { + "path": path, "kind": "enum_value_removed", "severity": "breaking", + "detail": f"enum value '{v}' removed", + } + for v in sorted(set(oe) - set(ne)) + ) + changes.extend( + { + "path": path, "kind": "enum_value_added", "severity": "additive", + "detail": f"enum value '{v}' added", + } + for v in sorted(set(ne) - set(oe)) + ) + + if op.get("default") != np.get("default"): + changes.append({ + "path": path, "kind": "default_changed", "severity": "additive", + "detail": f"default {op.get('default')} -> {np.get('default')}", + }) + + if op.get("description") != np.get("description"): + changes.append({ + "path": path, "kind": "description_changed", "severity": "cosmetic", + "detail": "description updated", + }) + + return changes + + +# --------------------------------------------------------------------------- +# Semver bump +# --------------------------------------------------------------------------- + +def recommend_bump(changes): + if any(c.get("severity") == "breaking" for c in changes): + return "major" + if any(c.get("severity") == "additive" for c in changes): + return "minor" + if any(c.get("severity") == "cosmetic" for c in changes): + return "patch" + return "none" + + +def apply_bump(version, bump): + if bump == "none": + return version + parts = version.split(".") + if len(parts) != 3 or not all(p.isdigit() for p in parts): + raise ValueError(f"version '{version}' is not semver MAJOR.MINOR.PATCH") + major, minor, patch = (int(p) for p in parts) + if bump == "major": + return f"{major + 1}.0.0" + if bump == "minor": + return f"{major}.{minor + 1}.0" + if bump == "patch": + return f"{major}.{minor}.{patch + 1}" + raise ValueError(f"unknown bump kind '{bump}'") + + +# Match a `version: X` line, capturing leading whitespace for round-trip. +_VERSION_LINE_RE = re.compile(r"(?m)^(\s*version:\s*)(\S+)(\s*)$") + + +def bump_version(yaml_path, bump, write): + """Read configurationDefinitions.yml, compute new version, optionally + write it back. Returns (old_version, new_version). + + Reading is a regex match (no PyYAML dep). Writing replaces only the + single `version:` line so the rest of the file is preserved byte-for-byte. + """ + text = Path(yaml_path).read_text(encoding="utf-8") + matches = list(_VERSION_LINE_RE.finditer(text)) + if len(matches) != 1: + raise RuntimeError( + f"{yaml_path}: expected exactly 1 'version:' line, found {len(matches)}" + ) + old_version = matches[0].group(2) + new_version = apply_bump(old_version, bump) + if write and new_version != old_version: + new_text = _VERSION_LINE_RE.sub( + lambda m: f"{m.group(1)}{new_version}{m.group(3)}", + text, + ) + Path(yaml_path).write_text(new_text, encoding="utf-8") + return old_version, new_version + + +# --------------------------------------------------------------------------- +# I/O +# --------------------------------------------------------------------------- + +def load_newrelic_ini(default_path): + env_path = os.environ.get("NEWRELIC_INI") + source = Path(env_path) if env_path else Path(default_path) + if not source.exists(): + raise FileNotFoundError( + f"newrelic.ini not found at {source.resolve()}. " + "Set NEWRELIC_INI to override the source path." + ) + print(f"Reading: {source.resolve()}") + return source.read_text(encoding="utf-8") + + +def load_existing(path): + if not Path(path).exists(): + return {} + try: + return json.loads(Path(path).read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + + +def write_schema(schema, path): + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8") + + +def validate_meta_schema(schema): + """Validate against JSON Schema 2020-12. Soft-skip if `jsonschema` is not + installed; hard-fail (exit 2) only on actual schema invalidity. + """ + try: + import jsonschema + except ImportError: + print(" meta-schema check skipped: jsonschema not installed", file=sys.stderr) + return + try: + jsonschema.Draft202012Validator.check_schema(schema) + print("Meta-schema validation passed (Draft 2020-12)") + except jsonschema.exceptions.SchemaError as e: + print("Meta-schema validation FAILED:", file=sys.stderr) + print(f" {e.message}", file=sys.stderr) + sys.exit(2) + except Exception as e: + print(f" meta-schema check skipped: {type(e).__name__}: {e}", file=sys.stderr) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(argv=None): + parser = argparse.ArgumentParser(description="Generate Fleet Control config schema.") + parser.add_argument("--ci", action="store_true", + help="Apply the recommended (or overridden) version bump in place.") + parser.add_argument("--bump", choices=["major", "minor", "patch", "none"], + help="Override the auto-recommended bump.") + args = parser.parse_args(argv) + + raw_text = load_newrelic_ini(DEFAULT_INI_PATH) + generated = generate_schema(raw_text) + + old_schema = load_existing(SCHEMA_PATH) + new_schema = merge_schemas(old_schema, generated) + + validate_meta_schema(new_schema) + + write_schema(new_schema, SCHEMA_PATH) + print(f"Wrote: {SCHEMA_PATH}") + + if not old_schema: + print("\nFirst run -- schema created.") + return 0 + + changes = classify_changes(old_schema, new_schema) + + if changes: + breaking = [c for c in changes if c["severity"] == "breaking"] + additive = [c for c in changes if c["severity"] == "additive"] + cosmetic = [c for c in changes if c["severity"] == "cosmetic"] + print(f"\nSchema changes ({len(changes)}):") + if breaking: + print(f" BREAKING ({len(breaking)}):") + for c in breaking: + print(f" {render_change(c)}") + if additive: + print(f" ADDITIVE ({len(additive)}):") + for c in additive: + print(f" {render_change(c)}") + if cosmetic: + print(f" COSMETIC ({len(cosmetic)}):") + for c in cosmetic: + print(f" {render_change(c)}") + else: + print("\nNo schema changes.") + + auto_bump = recommend_bump(changes) + chosen = args.bump or auto_bump + old_v, new_v = bump_version(CONFIG_DEF_PATH, chosen, args.ci) + if chosen == "none" or new_v == old_v: + print(f"\nRecommended bump: none ({old_v} unchanged)") + elif args.bump and args.bump != auto_bump: + print(f"\nRecommended bump: {auto_bump} -> overridden to {chosen} ({old_v} -> {new_v})") + else: + print(f"\nRecommended bump: {chosen} ({old_v} -> {new_v})") + if args.ci and new_v != old_v: + print(f"Wrote: {CONFIG_DEF_PATH}") + + return 1 if changes else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.fleetControl/schemaGeneration/tests/test_generate_schema.py b/.fleetControl/schemaGeneration/tests/test_generate_schema.py new file mode 100644 index 0000000000..dd0c1d6a27 --- /dev/null +++ b/.fleetControl/schemaGeneration/tests/test_generate_schema.py @@ -0,0 +1,557 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for generate-schema.py. + +Run from the repo root: + + python -m unittest discover .fleetControl/schemaGeneration/tests + +The generator script lives one level up; we load it via importlib.util +because the filename has a hyphen and is not importable as a module. +""" + +import importlib.util +import os +import tempfile +import textwrap +import unittest +from pathlib import Path + +# --------------------------------------------------------------------------- +# Load generate-schema.py as a module under the alias `gen` +# --------------------------------------------------------------------------- +_SCRIPT = Path(__file__).resolve().parent.parent / "generate-schema.py" +_spec = importlib.util.spec_from_file_location("gen", _SCRIPT) +gen = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(gen) + + +# --------------------------------------------------------------------------- +# Test-local override fixtures (mirror Java's pattern of passing override +# maps as parameters so production constants don't leak in) +# --------------------------------------------------------------------------- +TEST_ENUMS = {"log_level": ["off", "info", "debug"]} +TEST_TYPES = { + "error_collector.ignore_classes": { + "type": "array", "items": {"type": "string"}, "default": [], + }, +} +TEST_EXCLUDES = {"some_excluded_key"} + + +class InferTypeTests(unittest.TestCase): + def test_boolean(self): + self.assertEqual(gen.infer_type("true"), "boolean") + self.assertEqual(gen.infer_type("false"), "boolean") + self.assertEqual(gen.infer_type("True"), "boolean") + self.assertEqual(gen.infer_type("FALSE"), "boolean") + + def test_integer(self): + self.assertEqual(gen.infer_type("0"), "integer") + self.assertEqual(gen.infer_type("42"), "integer") + self.assertEqual(gen.infer_type("-1"), "integer") + + def test_number(self): + self.assertEqual(gen.infer_type("0.5"), "number") + self.assertEqual(gen.infer_type("-1.25"), "number") + + def test_string_default(self): + self.assertEqual(gen.infer_type("hello"), "string") + self.assertEqual(gen.infer_type("apdex_f"), "string") + + def test_empty_and_none(self): + self.assertEqual(gen.infer_type(""), "string") + self.assertEqual(gen.infer_type(None), "string") + + +class CoerceDefaultTests(unittest.TestCase): + def test_boolean(self): + self.assertIs(gen.coerce_default("true", "boolean"), True) + self.assertIs(gen.coerce_default("False", "boolean"), False) + + def test_integer(self): + self.assertEqual(gen.coerce_default("42", "integer"), 42) + + def test_number(self): + self.assertEqual(gen.coerce_default("0.5", "number"), 0.5) + + def test_string_preserves_input(self): + # No strip -- the caller passes the value as-is so spacing is preserved. + self.assertEqual(gen.coerce_default("Python Application", "string"), + "Python Application") + + +class ParseIniTests(unittest.TestCase): + def test_single_comment_attached_to_key(self): + text = "[newrelic]\n# my comment\nfoo = 1\n" + keys, comments = gen.parse_ini(text) + self.assertEqual(keys, {"foo": "1"}) + self.assertEqual(comments["foo"], "my comment") + + def test_multi_line_comment_joined(self): + text = "[newrelic]\n# line one\n# line two\nfoo = 1\n" + _, comments = gen.parse_ini(text) + self.assertEqual(comments["foo"], "line one line two") + + def test_blank_line_resets_pending(self): + text = "[newrelic]\n# stale comment\n\nfoo = 1\n" + _, comments = gen.parse_ini(text) + self.assertNotIn("foo", comments) + + def test_commented_out_example_does_not_bleed(self): + # Mirrors the proxy_host example block in newrelic.ini: a commented-out + # `# proxy_host = hostname` followed by a blank, then a real key with + # its own description must NOT inherit the proxy_host comment text. + text = textwrap.dedent("""\ + [newrelic] + # proxy_host = hostname + + # real description + transaction_tracer.enabled = true + """) + _, comments = gen.parse_ini(text) + self.assertEqual(comments["transaction_tracer.enabled"], "real description") + + def test_section_header_resets_pending(self): + text = textwrap.dedent("""\ + # stale top-of-file comment + [newrelic] + foo = 1 + """) + _, comments = gen.parse_ini(text) + self.assertNotIn("foo", comments) + + def test_only_named_section_is_parsed(self): + text = textwrap.dedent("""\ + [newrelic] + foo = 1 + [newrelic:production] + bar = 2 + """) + keys, _ = gen.parse_ini(text, section="newrelic") + self.assertIn("foo", keys) + self.assertNotIn("bar", keys) + + def test_dotted_keys_preserved(self): + text = "[newrelic]\ntransaction_tracer.enabled = true\n" + keys, _ = gen.parse_ini(text) + self.assertIn("transaction_tracer.enabled", keys) + + +class MakePropertyTests(unittest.TestCase): + def test_boolean_with_default(self): + p = gen.make_property("enabled", "true", "Enable the thing", + TEST_ENUMS, TEST_TYPES) + self.assertEqual(p["type"], "boolean") + self.assertIs(p["default"], True) + self.assertEqual(p["description"], "Enable the thing") + + def test_integer_no_description(self): + p = gen.make_property("count", "42", "", TEST_ENUMS, TEST_TYPES) + self.assertEqual(p["type"], "integer") + self.assertEqual(p["default"], 42) + self.assertNotIn("description", p) + + def test_empty_string_omits_default(self): + p = gen.make_property("ignore", "", "", TEST_ENUMS, TEST_TYPES) + self.assertEqual(p["type"], "string") + self.assertNotIn("default", p) + + def test_enum_override_with_matching_default(self): + p = gen.make_property("log_level", "info", "", TEST_ENUMS, TEST_TYPES) + self.assertEqual(p["type"], "string") + self.assertEqual(p["enum"], ["off", "info", "debug"]) + self.assertEqual(p["default"], "info") + + def test_enum_override_without_matching_default(self): + # Default not in enum -> no default emitted (avoid invalid schema). + p = gen.make_property("log_level", "verbose", "", TEST_ENUMS, TEST_TYPES) + self.assertEqual(p["enum"], ["off", "info", "debug"]) + self.assertNotIn("default", p) + + def test_type_override_takes_precedence(self): + p = gen.make_property("error_collector.ignore_classes", "FooException", + "doc", TEST_ENUMS, TEST_TYPES) + self.assertEqual(p["type"], "array") + self.assertEqual(p["items"], {"type": "string"}) + self.assertEqual(p["default"], []) + self.assertEqual(p["description"], "doc") + + +class BuildPropertiesTests(unittest.TestCase): + def test_excludes_keys(self): + keys = {"some_excluded_key": "true", "agent_enabled": "true"} + comments = {} + props = gen.build_properties(keys, comments, TEST_EXCLUDES, + TEST_ENUMS, TEST_TYPES) + self.assertNotIn("some_excluded_key", props) + self.assertIn("agent_enabled", props) + + def test_descriptions_attach(self): + keys = {"foo": "true"} + comments = {"foo": "foo description"} + props = gen.build_properties(keys, comments, TEST_EXCLUDES, + TEST_ENUMS, TEST_TYPES) + self.assertEqual(props["foo"]["description"], "foo description") + + +class GenerateSchemaIntegrationTests(unittest.TestCase): + """Exercise the full pipeline against an inline INI fixture.""" + + FIXTURE = textwrap.dedent("""\ + # Top-of-file comment that should NOT bleed into license_key. + + [newrelic] + # The license key. + license_key = *** REPLACE ME *** + + # The application name. + app_name = My App + + # Logging configuration. + log_level = info + + # Stale comment that should NOT bleed into the next key. + + # Real description for transaction_tracer.enabled. + transaction_tracer.enabled = true + + # Threshold for SQL stack trace. + transaction_tracer.stack_trace_threshold = 0.5 + + # Ignore list (override forces array). + error_collector.ignore_classes = + + [newrelic:production] + # This should never be parsed. + ignored_key = should_not_appear + """) + + def setUp(self): + self.fixture_enums = {"log_level": ["off", "info", "debug"]} + self.fixture_types = { + "error_collector.ignore_classes": { + "type": "array", "items": {"type": "string"}, "default": [], + }, + } + self.schema = gen.generate_schema( + self.FIXTURE, + exclude_keys=set(), + enum_overrides=self.fixture_enums, + type_overrides=self.fixture_types, + ) + + def test_top_level_required(self): + self.assertEqual(self.schema["required"], ["license_key", "app_name"]) + + def test_additional_properties_true(self): + self.assertTrue(self.schema["additionalProperties"]) + + def test_license_key_overridden(self): + lk = self.schema["properties"]["license_key"] + self.assertEqual(lk["type"], "string") + self.assertEqual(lk["minLength"], 1) + self.assertNotIn("default", lk) + self.assertIn("license key", lk["description"].lower()) + + def test_app_name_string_with_default(self): + an = self.schema["properties"]["app_name"] + self.assertEqual(an["type"], "string") + self.assertEqual(an["default"], "My App") + self.assertIn("application name", an["description"].lower()) + + def test_log_level_enum_with_default(self): + ll = self.schema["properties"]["log_level"] + self.assertEqual(ll["enum"], ["off", "info", "debug"]) + self.assertEqual(ll["default"], "info") + + def test_transaction_tracer_enabled_no_stale_comment(self): + prop = self.schema["properties"]["transaction_tracer.enabled"] + self.assertEqual(prop["type"], "boolean") + self.assertIs(prop["default"], True) + self.assertIn("Real description", prop["description"]) + self.assertNotIn("Stale comment", prop["description"]) + + def test_float_inferred_as_number(self): + prop = self.schema["properties"]["transaction_tracer.stack_trace_threshold"] + self.assertEqual(prop["type"], "number") + self.assertEqual(prop["default"], 0.5) + + def test_type_override_applied(self): + prop = self.schema["properties"]["error_collector.ignore_classes"] + self.assertEqual(prop["type"], "array") + self.assertEqual(prop["items"], {"type": "string"}) + self.assertEqual(prop["default"], []) + + def test_environment_section_ignored(self): + self.assertNotIn("ignored_key", self.schema["properties"]) + + +class MergeSchemasTests(unittest.TestCase): + def test_empty_old_returns_new(self): + new = {"type": "object", "properties": {"foo": {"type": "string"}}} + self.assertEqual(gen.merge_schemas({}, new), new) + + def test_keys_only_in_old_preserved(self): + old = {"type": "object", "properties": {"legacy": {"type": "string", "default": "x"}}} + new = {"type": "object", "properties": {"fresh": {"type": "integer"}}} + merged = gen.merge_schemas(old, new) + self.assertIn("legacy", merged["properties"]) + self.assertIn("fresh", merged["properties"]) + self.assertEqual(merged["properties"]["legacy"]["default"], "x") + + def test_keys_in_both_new_wins(self): + old = {"type": "object", "properties": {"foo": {"type": "string", "default": "old"}}} + new = {"type": "object", "properties": {"foo": {"type": "string", "default": "new"}}} + merged = gen.merge_schemas(old, new) + self.assertEqual(merged["properties"]["foo"]["default"], "new") + + def test_top_level_required_uses_new(self): + old = {"type": "object", "properties": {"foo": {"type": "string"}}, "required": ["foo"]} + new = {"type": "object", "properties": {"foo": {"type": "string"}}, "required": []} + merged = gen.merge_schemas(old, new) + self.assertEqual(merged["required"], []) + + def test_type_change_clears_stale_constraints(self): + old = {"type": "object", "properties": {"x": {"type": "string", "enum": ["a", "b"]}}} + new = {"type": "object", "properties": {"x": {"type": "integer", "default": 5}}} + merged = gen.merge_schemas(old, new) + x = merged["properties"]["x"] + self.assertEqual(x["type"], "integer") + self.assertEqual(x["default"], 5) + self.assertNotIn("enum", x) + + def test_top_level_title_takes_new(self): + old = {"type": "object", "properties": {}, "title": "old", "description": "old"} + new = {"type": "object", "properties": {}, "title": "new", "description": "new"} + merged = gen.merge_schemas(old, new) + self.assertEqual(merged["title"], "new") + self.assertEqual(merged["description"], "new") + + +def _obj(props, required=None, additional=True): + node = {"type": "object", "properties": props, + "additionalProperties": additional} + if required is not None: + node["required"] = required + return node + + +def _by_kind(changes): + return {c["kind"]: c for c in changes} + + +class ClassifyChangesTests(unittest.TestCase): + def test_no_changes(self): + s = _obj({"foo": {"type": "string", "default": "x"}}) + self.assertEqual(gen.classify_changes(s, s), []) + + def test_added_is_additive(self): + ch = gen.classify_changes(_obj({}), _obj({"foo": {"type": "string"}})) + self.assertEqual(len(ch), 1) + self.assertEqual(ch[0]["path"], "foo") + self.assertEqual(ch[0]["severity"], "additive") + + def test_removed_is_breaking(self): + ch = gen.classify_changes(_obj({"foo": {"type": "string"}}), _obj({})) + self.assertEqual(ch[0]["kind"], "removed") + self.assertEqual(ch[0]["severity"], "breaking") + + def test_type_change_is_breaking(self): + ch = _by_kind(gen.classify_changes( + _obj({"foo": {"type": "string"}}), + _obj({"foo": {"type": "integer"}}), + )) + self.assertEqual(ch["type_changed"]["severity"], "breaking") + self.assertIn("string", ch["type_changed"]["detail"]) + self.assertIn("integer", ch["type_changed"]["detail"]) + + def test_required_added_is_breaking(self): + ch = _by_kind(gen.classify_changes( + _obj({"foo": {"type": "string"}}, []), + _obj({"foo": {"type": "string"}}, ["foo"]), + )) + self.assertEqual(ch["required_added"]["severity"], "breaking") + + def test_required_removed_is_additive(self): + ch = _by_kind(gen.classify_changes( + _obj({"foo": {"type": "string"}}, ["foo"]), + _obj({"foo": {"type": "string"}}, []), + )) + self.assertEqual(ch["required_removed"]["severity"], "additive") + + def test_additional_properties_tightened_is_breaking(self): + ch = _by_kind(gen.classify_changes( + _obj({}, None, True), _obj({}, None, False), + )) + self.assertEqual(ch["additional_properties_tightened"]["severity"], "breaking") + + def test_additional_properties_implicit_true_matches_explicit(self): + old = {"type": "object", "properties": {}} + new = {"type": "object", "properties": {}, "additionalProperties": True} + self.assertEqual(gen.classify_changes(old, new), []) + + def test_enum_value_removed_is_breaking(self): + ch = gen.classify_changes( + _obj({"x": {"type": "string", "enum": ["a", "b", "c"]}}), + _obj({"x": {"type": "string", "enum": ["a", "c"]}}), + ) + removed = next(c for c in ch if c["kind"] == "enum_value_removed") + self.assertEqual(removed["severity"], "breaking") + self.assertIn("'b'", removed["detail"]) + + def test_enum_value_added_is_additive(self): + ch = gen.classify_changes( + _obj({"x": {"type": "string", "enum": ["a"]}}), + _obj({"x": {"type": "string", "enum": ["a", "b"]}}), + ) + added = next(c for c in ch if c["kind"] == "enum_value_added") + self.assertEqual(added["severity"], "additive") + + def test_enum_introduced_is_breaking(self): + ch = _by_kind(gen.classify_changes( + _obj({"x": {"type": "string"}}), + _obj({"x": {"type": "string", "enum": ["a", "b"]}}), + )) + self.assertEqual(ch["enum_introduced"]["severity"], "breaking") + + def test_default_changed_is_additive(self): + ch = _by_kind(gen.classify_changes( + _obj({"x": {"type": "string", "default": "a"}}), + _obj({"x": {"type": "string", "default": "b"}}), + )) + self.assertEqual(ch["default_changed"]["severity"], "additive") + + def test_description_changed_is_cosmetic(self): + ch = _by_kind(gen.classify_changes( + _obj({"x": {"type": "string", "description": "old"}}), + _obj({"x": {"type": "string", "description": "new"}}), + )) + self.assertEqual(ch["description_changed"]["severity"], "cosmetic") + + +class RenderChangeTests(unittest.TestCase): + def test_added(self): + self.assertEqual( + gen.render_change({"path": "foo.bar", "kind": "added", + "severity": "additive", "detail": "new property"}), + "+ foo.bar: new property", + ) + + def test_removed_no_detail(self): + self.assertEqual( + gen.render_change({"path": "foo", "kind": "removed", + "severity": "breaking", "detail": ""}), + "- foo", + ) + + def test_type_changed(self): + self.assertEqual( + gen.render_change({"path": "foo", "kind": "type_changed", + "severity": "breaking", "detail": "type x -> y"}), + "~ foo: type x -> y", + ) + + +class RecommendBumpTests(unittest.TestCase): + def test_any_breaking_is_major(self): + ch = [{"severity": "cosmetic"}, {"severity": "additive"}, {"severity": "breaking"}] + self.assertEqual(gen.recommend_bump(ch), "major") + + def test_additive_without_breaking_is_minor(self): + self.assertEqual(gen.recommend_bump( + [{"severity": "cosmetic"}, {"severity": "additive"}]), "minor") + + def test_cosmetic_only_is_patch(self): + self.assertEqual(gen.recommend_bump([{"severity": "cosmetic"}]), "patch") + + def test_empty_is_none(self): + self.assertEqual(gen.recommend_bump([]), "none") + + +class ApplyBumpTests(unittest.TestCase): + def test_major(self): + self.assertEqual(gen.apply_bump("1.2.3", "major"), "2.0.0") + + def test_minor(self): + self.assertEqual(gen.apply_bump("1.2.3", "minor"), "1.3.0") + + def test_patch(self): + self.assertEqual(gen.apply_bump("1.2.3", "patch"), "1.2.4") + + def test_none_passthrough(self): + self.assertEqual(gen.apply_bump("1.2.3", "none"), "1.2.3") + + def test_non_semver_raises(self): + with self.assertRaises(ValueError): + gen.apply_bump("not-semver", "major") + + +FIXTURE_YAML = textwrap.dedent("""\ + configurationDefinitions: + - platform: KUBERNETESCLUSTER + description: Test agent configuration + type: agent-config + version: 1.2.3 + schema: ./schemas/config.json + format: ini + """) + + +class BumpVersionTests(unittest.TestCase): + def _temp_yaml(self, content=FIXTURE_YAML): + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".yml", delete=False, encoding="utf-8" + ) + f.write(content) + f.close() + self.addCleanup(os.unlink, f.name) + return Path(f.name) + + def test_read_returns_old_new(self): + path = self._temp_yaml() + old_v, new_v = gen.bump_version(path, "minor", False) + self.assertEqual(old_v, "1.2.3") + self.assertEqual(new_v, "1.3.0") + + def test_write_false_does_not_touch_file(self): + path = self._temp_yaml() + before = path.read_text() + gen.bump_version(path, "major", False) + self.assertEqual(path.read_text(), before) + + def test_write_true_mutates(self): + path = self._temp_yaml() + gen.bump_version(path, "major", True) + self.assertIn("version: 2.0.0", path.read_text()) + # And nothing else should change. + self.assertIn("description: Test agent configuration", path.read_text()) + self.assertIn("schema: ./schemas/config.json", path.read_text()) + + def test_none_bump_no_op_even_with_write(self): + path = self._temp_yaml() + before = path.read_text() + old_v, new_v = gen.bump_version(path, "none", True) + self.assertEqual(old_v, new_v) + self.assertEqual(path.read_text(), before) + + def test_missing_version_raises(self): + path = self._temp_yaml("configurationDefinitions:\n - platform: foo\n") + with self.assertRaises(RuntimeError): + gen.bump_version(path, "major", False) + + +if __name__ == "__main__": + unittest.main() diff --git a/.fleetControl/schemas/config.json b/.fleetControl/schemas/config.json new file mode 100644 index 0000000000..7cbc8cac9d --- /dev/null +++ b/.fleetControl/schemas/config.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "New Relic Python Agent Configuration", + "description": "Fleet Control configuration schema for the New Relic Python agent. Generated from newrelic/newrelic.ini.", + "type": "object", + "properties": { + "license_key": { + "type": "string", + "description": "New Relic license key associated with your account. Binds the agent's data to your account in the New Relic UI.", + "minLength": 1 + }, + "app_name": { + "type": "string", + "default": "Python Application", + "description": "The application name. Set this to be the name of your application as you would like it to show up in New Relic UI. You may also set this using the NEW_RELIC_APP_NAME environment variable. The UI will then auto-map instances of your application into a entry on your home dashboard page. You can also specify multiple app names to group your aggregated data. For further details, please see: https://docs.newrelic.com/docs/apm/agents/manage-apm-agents/app-naming/use-multiple-names-app/" + }, + "monitor_mode": { + "type": "boolean", + "default": true, + "description": "When \"true\", the agent collects performance data about your application and reports this data to the New Relic UI at newrelic.com. This global switch is normally overridden for each environment below. It may also be set using the NEW_RELIC_MONITOR_MODE environment variable." + }, + "log_file": { + "type": "string", + "default": "stdout", + "description": "Sets the name of a file to log agent messages to. Whatever you set this to, you must ensure that the permissions for the containing directory and the file itself are correct, and that the user that your web application runs as can write out to the file. If not able to out a log file, it is also possible to say \"stderr\" and output to standard error output. This would normally result in output appearing in your web server log. It can also be set using the NEW_RELIC_LOG environment variable." + }, + "log_level": { + "type": "string", + "enum": [ + "critical", + "error", + "warning", + "info", + "debug" + ], + "default": "info", + "description": "Sets the level of detail of messages sent to the log file, if a log file location has been provided. Possible values, in increasing order of detail, are: \"critical\", \"error\", \"warning\", \"info\" and \"debug\". When reporting any agent issues to New Relic technical support, the most useful setting for the support engineers is \"debug\". However, this can generate a lot of information very quickly, so it is best not to keep the agent at this level for longer than it takes to reproduce the problem you are experiencing. This may also be set using the NEW_RELIC_LOG_LEVEL environment variable." + }, + "high_security": { + "type": "boolean", + "default": false, + "description": "High Security Mode enforces certain security settings, and prevents them from being overridden, so that no sensitive data is sent to New Relic. Enabling High Security Mode means that request parameters are not collected and SQL can not be sent to New Relic in its raw form. To activate High Security Mode, it must be set to 'true' in this local .ini configuration file AND be set to 'true' in the server-side configuration in the New Relic user interface. It can also be set using the NEW_RELIC_HIGH_SECURITY environment variable. For details, see https://docs.newrelic.com/docs/subscriptions/high-security" + }, + "transaction_tracer.enabled": { + "type": "boolean", + "default": true, + "description": "The transaction tracer captures deep information about slow transactions and sends this to the UI on a periodic basis. The transaction tracer is enabled by default. Set this to \"false\" to turn it off." + }, + "transaction_tracer.transaction_threshold": { + "type": "string", + "default": "apdex_f", + "description": "Threshold in seconds for when to collect a transaction trace. When the response time of a controller action exceeds this threshold, a transaction trace will be recorded and sent to the UI. Valid values are any positive float value, or (default) \"apdex_f\", which will use the threshold for a dissatisfying Apdex controller action - four times the Apdex T value." + }, + "transaction_tracer.record_sql": { + "type": "string", + "enum": [ + "off", + "raw", + "obfuscated" + ], + "default": "obfuscated", + "description": "When the transaction tracer is on, SQL statements can optionally be recorded. The recorder has three modes, \"off\" which sends no SQL, \"raw\" which sends the SQL statement in its original form, and \"obfuscated\", which strips out numeric and string literals." + }, + "transaction_tracer.stack_trace_threshold": { + "type": "number", + "default": 0.5, + "description": "Threshold in seconds for when to collect stack trace for a SQL call. In other words, when SQL statements exceed this threshold, then capture and send to the UI the current stack trace. This is helpful for pinpointing where long SQL calls originate from in an application." + }, + "transaction_tracer.explain_enabled": { + "type": "boolean", + "default": true, + "description": "Determines whether the agent will capture query plans for slow SQL queries. Only supported in MySQL and PostgreSQL. Set this to \"false\" to turn it off." + }, + "transaction_tracer.explain_threshold": { + "type": "number", + "default": 0.5, + "description": "Threshold for query execution time below which query plans will not not be captured. Relevant only when \"explain_enabled\" is true." + }, + "transaction_tracer.function_trace": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Space separated list of function or method names in form 'module:function' or 'module:class.function' for which additional function timing instrumentation will be added." + }, + "error_collector.enabled": { + "type": "boolean", + "default": true, + "description": "The error collector captures information about uncaught exceptions or logged exceptions and sends them to UI for viewing. The error collector is enabled by default. Set this to \"false\" to turn it off. For more details on errors, see https://docs.newrelic.com/docs/apm/agents/manage-apm-agents/agent-data/manage-errors-apm-collect-ignore-or-mark-expected/" + }, + "error_collector.ignore_classes": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "To stop specific errors from reporting to the UI, set this to a space separated list of the Python exception type names to ignore. The exception name should be of the form 'module:class'." + }, + "error_collector.expected_classes": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Expected errors are reported to the UI but will not affect the Apdex or error rate. To mark specific errors as expected, set this to a space separated list of the Python exception type names to expected. The exception name should be of the form 'module:class'." + }, + "browser_monitoring.auto_instrument": { + "type": "boolean", + "default": true, + "description": "Browser monitoring is the Real User Monitoring feature of the UI. For those Python web frameworks that are supported, this setting enables the auto-insertion of the browser monitoring JavaScript fragments." + }, + "thread_profiler.enabled": { + "type": "boolean", + "default": true, + "description": "A thread profiling session can be scheduled via the UI when this option is enabled. The thread profiler will periodically capture a snapshot of the call stack for each active thread in the application to construct a statistically representative call tree. For more details on the thread profiler tool, see https://docs.newrelic.com/docs/apm/apm-ui-pages/events/thread-profiler-tool/" + }, + "distributed_tracing.enabled": { + "type": "boolean", + "default": true, + "description": "Distributed tracing lets you see the path that a request takes through your distributed system. For more information, please consult our distributed tracing planning guide. https://docs.newrelic.com/docs/transition-guide-distributed-tracing" + } + }, + "required": [ + "license_key", + "app_name" + ], + "additionalProperties": true +} diff --git a/.github/workflows/fleet-control-schema.yml b/.github/workflows/fleet-control-schema.yml new file mode 100644 index 0000000000..312145d194 --- /dev/null +++ b/.github/workflows/fleet-control-schema.yml @@ -0,0 +1,107 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Fleet Control Config Schema + +# Updates .fleetControl/schemas/config.json from newrelic/newrelic.ini +# whenever a PR against main touches inputs that could affect the schema. +# The generator never starts fresh -- it deep-merges the freshly generated +# schema into the existing config.json so the published schema only ever +# grows (keys present in the old schema are preserved). +# +# The generator is run with --ci, which: +# - exits 0 if the updated schema matches what's already on disk +# (no changes; nothing to commit), +# - exits 1 if the schema changed, in which case it has already written +# the updated config.json AND bumped the version in +# .fleetControl/configurationDefinitions.yml. The "Commit and push" +# step then commits both files back to the PR branch. +# - exits >=2 on a hard failure (invalid schema, malformed inputs). +# +# Auto-commit only works for PRs from branches in this repository -- pushes +# to fork branches require write access we don't have. PRs from forks will +# skip the update; reviewers can run the generator locally +# (`python .fleetControl/schemaGeneration/generate-schema.py --ci`) and +# ask the contributor to pull. + +permissions: {} + +on: + pull_request: + branches: [ main ] + paths: + - 'newrelic/newrelic.ini' + - '.fleetControl/schemaGeneration/**' + - '.fleetControl/schemas/**' + - '.fleetControl/configurationDefinitions.yml' + workflow_dispatch: + +concurrency: + group: fleet-control-schema-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + update: + name: Update config schema + runs-on: ubuntu-24.04 + # GITHUB_TOKEN cannot push to fork branches, so skip fork PRs. + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + + steps: + - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # pin@v4 + with: + ref: ${{ github.event.pull_request.head.ref || github.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Run schema generator + id: generate + run: | + set +e + python .fleetControl/schemaGeneration/generate-schema.py --ci + code=$? + set -e + case "$code" in + 0) echo "changed=false" >> "$GITHUB_OUTPUT" ;; + 1) echo "changed=true" >> "$GITHUB_OUTPUT" ;; + *) echo "Schema generator failed (exit $code)"; exit "$code" ;; + esac + + - name: Run schema generator tests + run: python -m unittest discover .fleetControl/schemaGeneration/tests + + - name: Commit and push updated schema + if: steps.generate.outputs.changed == 'true' + env: + # Pass the (potentially attacker-controlled) PR branch name through + # an env var rather than interpolating it directly into the shell + # script -- prevents script injection via crafted branch names. + HEAD_REF: ${{ github.event.pull_request.head.ref || github.ref_name }} + run: | + if [ -z "$(git status --porcelain .fleetControl)" ]; then + echo "Generator reported changes but working tree is clean -- nothing to commit." + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add .fleetControl/schemas/config.json .fleetControl/configurationDefinitions.yml + git commit -m "chore: update Fleet Control config schema" + git push origin "HEAD:$HEAD_REF" diff --git a/pyproject.toml b/pyproject.toml index 70700d287e..728d3c4952 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -252,6 +252,15 @@ ignore = [ # Disabled rules in admin scripts "S108", # flake8-bandit (hardcoded log files are never used as input) ] +".fleetControl/schemaGeneration/*" = [ + # Standalone build-time generator + its tests; not part of the agent package. + "INP", # flake8-no-pep420 (no __init__.py; this is a script, not a package) +] +".fleetControl/schemaGeneration/tests/*" = [ + # Schema generator tests use stdlib unittest (no pytest dependency). + "PT009", # use-of-assertEqual (we deliberately use unittest assertions) + "PT027", # use-of-assertRaises (we deliberately use unittest assertions) +] # ========================= # Other Tools Configuration