|
| 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