Skip to content

Commit dd5070d

Browse files
committed
chore(ci): fail CI when defaults/main.yml drifts from argument_specs.yml
Adds scripts/check_argspecs.py — for every role that ships a meta/argument_specs.yml, compare the set of top-level (non-underscored) variables in defaults/main.yml against the options declared in the spec. Any mismatch either way (var in defaults but not spec, option in spec but no longer in defaults) exits non-zero with the offending names and a pointer to scripts/gen_argspecs.py for regeneration. Wired into test_linting.yml as the last step of the whole-collection lint job (skipped for the per-role invocation so we do not run five duplicate checks per PR). Also promotes gen_argspecs.py from /tmp/ into scripts/ so the fix path is self-contained, and uncomments elasticstack_cert_pass in defaults (one commented line the initial argument_specs PR left as spec-only drift — flipping to elasticstack_cert_pass: "" matches the actual role behaviour and clears the check).
1 parent 29ee940 commit dd5070d

4 files changed

Lines changed: 320 additions & 1 deletion

File tree

.github/workflows/test_linting.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,11 @@ jobs:
8484
env:
8585
ROLENAME: ${{ inputs.rolename }}
8686
if: ${{ inputs.rolename != '' }}
87+
88+
# Fails if a role's meta/argument_specs.yml is out of sync with its
89+
# defaults/main.yml — catches "I added a var but forgot to update the
90+
# spec" and its inverse. See scripts/gen_argspecs.py for regeneration.
91+
- name: Check argument_specs drift.
92+
run: |
93+
python3 scripts/check_argspecs.py
94+
if: ${{ inputs.rolename == '' }}

roles/elasticstack/defaults/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ elasticstack_ca_pass: PleaseChangeMe
4242
# logstash_tls_key_passphrase, and beats_tls_key_passphrase.
4343
# Leave empty to use the per-role passphrases.
4444
# @end
45-
# elasticstack_cert_pass:
45+
elasticstack_cert_pass: ""
4646
# @var elasticstack_ca_validity_period:description: Validity period in days for the CA certificate
4747
elasticstack_ca_validity_period: 1095
4848
# === General Settings ===

scripts/check_argspecs.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Verify that every role's meta/argument_specs.yml lists the same variables
4+
its defaults/main.yml defines. Fails CI when the two drift.
5+
6+
Runs across every role that has a meta/argument_specs.yml. Roles without
7+
one (e.g. `repos`, which uses variables from `elasticstack` instead) are
8+
skipped intentionally.
9+
10+
Exit codes:
11+
0 All roles in sync
12+
1 At least one role has drift (details on stderr)
13+
2 Usage / setup error
14+
15+
The parser only looks at the top-level `varname: value` lines in
16+
defaults/main.yml. Internal-only vars (leading underscore) are ignored
17+
so vars/main.yml-style facts don't need arg-spec entries.
18+
"""
19+
import sys
20+
import re
21+
from pathlib import Path
22+
23+
try:
24+
import yaml
25+
except ImportError:
26+
print("check_argspecs: PyYAML is required (pip install pyyaml)", file=sys.stderr)
27+
sys.exit(2)
28+
29+
30+
VAR_LINE = re.compile(r"^([A-Za-z][\w]*)\s*:")
31+
32+
33+
def defaults_vars(path):
34+
"""Top-level variable names in a defaults/main.yml, minus internal underscore vars."""
35+
out = set()
36+
with open(path) as f:
37+
for line in f:
38+
m = VAR_LINE.match(line)
39+
if m:
40+
name = m.group(1)
41+
if not name.startswith("_"):
42+
out.add(name)
43+
return out
44+
45+
46+
def argspec_options(path):
47+
"""Option names declared under any entry point (main + role-scoped
48+
task-file entry points like node_maintenance_start / _end)."""
49+
with open(path) as f:
50+
spec = yaml.safe_load(f) or {}
51+
out = set()
52+
for entry in (spec.get("argument_specs") or {}).values():
53+
for name in (entry.get("options") or {}).keys():
54+
out.add(name)
55+
return out
56+
57+
58+
def main():
59+
repo = Path(__file__).resolve().parent.parent
60+
roles = sorted(p for p in (repo / "roles").iterdir() if p.is_dir())
61+
exit_code = 0
62+
63+
for role in roles:
64+
defaults = role / "defaults" / "main.yml"
65+
specs = role / "meta" / "argument_specs.yml"
66+
if not specs.exists():
67+
continue # role opts out
68+
if not defaults.exists():
69+
print(
70+
f"[{role.name}] has meta/argument_specs.yml but no defaults/main.yml",
71+
file=sys.stderr,
72+
)
73+
exit_code = 1
74+
continue
75+
76+
vars_ = defaults_vars(defaults)
77+
opts = argspec_options(specs)
78+
missing_in_spec = vars_ - opts
79+
extra_in_spec = opts - vars_
80+
81+
if missing_in_spec or extra_in_spec:
82+
exit_code = 1
83+
print(f"\n[{role.name}] argument_specs drift:", file=sys.stderr)
84+
for v in sorted(missing_in_spec):
85+
print(f" - in defaults but missing from spec: {v}", file=sys.stderr)
86+
for v in sorted(extra_in_spec):
87+
print(f" - in spec but no longer in defaults: {v}", file=sys.stderr)
88+
else:
89+
print(f"[{role.name}] ok ({len(vars_)} vars)")
90+
91+
if exit_code:
92+
print(
93+
"\nFix by editing meta/argument_specs.yml or regenerating with "
94+
"`scripts/gen_argspecs.py <role_path>`.",
95+
file=sys.stderr,
96+
)
97+
sys.exit(exit_code)
98+
99+
100+
if __name__ == "__main__":
101+
main()

scripts/gen_argspecs.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generate meta/argument_specs.yml for an Ansible role by parsing @var
4+
docblocks in defaults/main.yml.
5+
6+
Usage: gen_argspecs.py <role_path>
7+
Writes: <role_path>/meta/argument_specs.yml
8+
"""
9+
import sys
10+
import re
11+
import yaml
12+
from pathlib import Path
13+
14+
15+
def parse_defaults(path):
16+
"""Returns list of dicts: [{'name': str, 'description': str, 'default': any}]"""
17+
with open(path) as f:
18+
text = f.read()
19+
lines = text.splitlines()
20+
entries = []
21+
i = 0
22+
pending_desc = None
23+
pending_var = None
24+
while i < len(lines):
25+
line = lines[i]
26+
27+
# Single-line @var: `# @var NAME:description: TEXT`
28+
m = re.match(r"^# @var\s+([\w.]+):description:\s*(.*)$", line)
29+
if m and not m.group(2).endswith('>'):
30+
pending_var = m.group(1)
31+
pending_desc = m.group(2).strip()
32+
i += 1
33+
continue
34+
35+
# Multi-line @var: `# @var NAME:description: >` then lines until `# @end`
36+
# OR the first non-comment line (some docblocks in this repo omit @end).
37+
m = re.match(r"^# @var\s+([\w.]+):description:\s*>\s*$", line)
38+
if m:
39+
pending_var = m.group(1)
40+
desc_lines = []
41+
i += 1
42+
while i < len(lines):
43+
stripped = lines[i].strip()
44+
if stripped.startswith("# @end"):
45+
i += 1
46+
break
47+
if stripped.startswith("# @var"):
48+
# A new @var starts (typically an :example: sibling) —
49+
# description ends here, leave i pointing at that line.
50+
break
51+
if not stripped.startswith("#"):
52+
# Docblock ended without @end — leave i pointing at
53+
# the value line and let the value-line branch handle it.
54+
break
55+
desc_lines.append(lines[i].lstrip("# ").rstrip())
56+
i += 1
57+
pending_desc = " ".join(l for l in desc_lines if l).strip()
58+
continue
59+
60+
# Also skip `# @var ...:example:` blocks
61+
if re.match(r"^# @var\s+[\w.]+:example:", line):
62+
# skip the example block similarly
63+
if line.rstrip().endswith('>'):
64+
i += 1
65+
while i < len(lines) and not lines[i].strip().startswith("# @end"):
66+
i += 1
67+
if i < len(lines):
68+
i += 1
69+
else:
70+
i += 1
71+
continue
72+
73+
# Value line: NAME: VALUE (or NAME: on its own for multi-line YAML)
74+
m = re.match(r"^(\w+):\s*(.*)$", line)
75+
if m and pending_var == m.group(1):
76+
varname = m.group(1)
77+
# Try to YAML-parse the full block (may span lines for lists/dicts)
78+
# Simple heuristic: take from here until next blank line or next @var
79+
block_end = i + 1
80+
while block_end < len(lines):
81+
nxt = lines[block_end]
82+
if (
83+
nxt.strip() == ""
84+
or nxt.startswith("# @var")
85+
or nxt.startswith("# ==")
86+
):
87+
break
88+
block_end += 1
89+
block_text = "\n".join(lines[i:block_end])
90+
try:
91+
parsed = yaml.safe_load(block_text)
92+
if isinstance(parsed, dict) and varname in parsed:
93+
default_val = parsed[varname]
94+
else:
95+
default_val = None
96+
except Exception:
97+
default_val = None
98+
entries.append(
99+
{"name": varname, "description": pending_desc or "", "default": default_val}
100+
)
101+
pending_var = None
102+
pending_desc = None
103+
i = block_end
104+
continue
105+
106+
# Also handle: commented-out defaults (# varname:) — skip but record with unset default
107+
m = re.match(r"^#\s*(\w+):\s*$", line)
108+
if m and pending_var == m.group(1):
109+
entries.append(
110+
{"name": m.group(1), "description": pending_desc or "", "default": None}
111+
)
112+
pending_var = None
113+
pending_desc = None
114+
i += 1
115+
continue
116+
117+
i += 1
118+
return entries
119+
120+
121+
def infer_type(value):
122+
if isinstance(value, bool):
123+
return "bool"
124+
if isinstance(value, int):
125+
return "int"
126+
if isinstance(value, float):
127+
return "float"
128+
if isinstance(value, list):
129+
return "list"
130+
if isinstance(value, dict):
131+
return "dict"
132+
if isinstance(value, str):
133+
# Jinja-templated strings still get type str
134+
return "str"
135+
return "str"
136+
137+
138+
def build_argument_specs(role_name, entries, short_desc, long_desc):
139+
options = {}
140+
for e in entries:
141+
opt = {"description": e["description"] or f"See defaults/main.yml for {e['name']}."}
142+
if e["default"] is not None:
143+
opt["type"] = infer_type(e["default"])
144+
opt["default"] = e["default"]
145+
else:
146+
opt["type"] = "raw"
147+
options[e["name"]] = opt
148+
return {
149+
"argument_specs": {
150+
"main": {
151+
"short_description": short_desc,
152+
"description": long_desc,
153+
"options": options,
154+
}
155+
}
156+
}
157+
158+
159+
ROLE_METADATA = {
160+
"elasticsearch": (
161+
"Install, configure, and manage Elasticsearch",
162+
"Handles cluster formation, TLS certificate management, security setup (users, passwords, HTTPS), rolling upgrades (8.x to 9.x), JVM tuning, and systemd service management.",
163+
),
164+
"kibana": (
165+
"Install, configure, and manage Kibana",
166+
"Handles package install, TLS setup for the Kibana web UI, keystore-managed secrets, integration with an Elasticsearch backend, and systemd service management.",
167+
),
168+
"logstash": (
169+
"Install, configure, and manage Logstash",
170+
"Handles package install, pipeline configuration, TLS certificate management for input/output, JVM tuning, and systemd service management.",
171+
),
172+
"beats": (
173+
"Install, configure, and manage Elastic Beats (filebeat, metricbeat, auditbeat)",
174+
"Handles package install, ECS-schema output configuration to Elasticsearch or Logstash, TLS certificate distribution, and systemd service management per beat.",
175+
),
176+
"elasticstack": (
177+
"Shared defaults and CA management for the oddly.elasticstack collection",
178+
"Provides collection-wide variables (inventory group names, ports, CA host, certificate settings) and the internal certificate authority workflow used by the elasticsearch, kibana, logstash, and beats roles.",
179+
),
180+
"repos": (
181+
"Manage Elastic package repositories",
182+
"Installs the Elastic apt/yum repository configuration matching elasticstack_release and elasticstack_repo_base_url, keeping the elasticsearch/kibana/logstash/beats package installs pointed at the right major version.",
183+
),
184+
}
185+
186+
187+
def main():
188+
role_path = Path(sys.argv[1]).resolve()
189+
role_name = role_path.name
190+
defaults = role_path / "defaults" / "main.yml"
191+
if not defaults.exists():
192+
print(f"no defaults/main.yml at {defaults}", file=sys.stderr)
193+
sys.exit(1)
194+
195+
entries = parse_defaults(defaults)
196+
short_desc, long_desc = ROLE_METADATA.get(
197+
role_name, (f"Role {role_name}", f"Role {role_name}.")
198+
)
199+
spec = build_argument_specs(role_name, entries, short_desc, long_desc)
200+
201+
(role_path / "meta").mkdir(exist_ok=True)
202+
outpath = role_path / "meta" / "argument_specs.yml"
203+
with open(outpath, "w") as f:
204+
f.write("---\n")
205+
yaml.dump(spec, f, sort_keys=False, default_flow_style=False, width=120)
206+
print(f"wrote {outpath} with {len(entries)} options")
207+
208+
209+
if __name__ == "__main__":
210+
main()

0 commit comments

Comments
 (0)