""" Sync human users from Delphi Terraform config into Orchard Terraform config. Reads: /prod/snowflake/delphi/human_users/terraform.tfvars /prod/snowflake/orchard/human_users/terraform.tfvars Each file must contain a single 'users' variable — a map of user objects. Sync rules applied to the Orchard map: - Users only in Delphi → added to Orchard unchanged. - Users present in both accounts: * roles : merged (union) — all Delphi roles added to Orchard roles. * disabled: removed from Orchard if Delphi user lacks it or has it False. * comment : Delphi value takes priority if explicitly set. * rsa_public_key: - Delphi has key, Orchard does not → copy to rsa_public_key. - Both have a key → copy Delphi key to rsa_public_key_2 (only if rsa_public_key_2 not already set). Output: /prod/snowflake/orchard/human_users/terraform_candidate.tfvars Usage: pip install python-hcl2 python3 sync-snowflake-users.py --terraform-infra /path/to/terraform-infra [--dry-run] """ import argparse import sys from pathlib import Path try: import hcl2 except ImportError: sys.exit( "python-hcl2 is required.\n" "Install it with: pip install python-hcl2" ) DELPHI_TFVARS_REL = "prod/snowflake/delphi/human_users/terraform.tfvars" ORCHARD_TFVARS_REL = "prod/snowflake/orchard/human_users/terraform.tfvars" OUTPUT_REL = "prod/snowflake/orchard/human_users/generated.tfvars" def _strip_quotes(value): """ python-hcl2 sometimes preserves the literal surrounding double-quotes from HCL string tokens (e.g. '"AACEVEDO"' instead of 'AACEVEDO'). Strip them recursively from all string values and dict keys. """ if isinstance(value, str): return value.strip('"') if isinstance(value, list): return [_strip_quotes(v) for v in value] if isinstance(value, dict): return {_strip_quotes(k): _strip_quotes(v) for k, v in value.items()} return value def load_tfvars(path: Path) -> dict: with open(path, "r", encoding="utf-8") as fh: return _strip_quotes(hcl2.load(fh)) # type: ignore[return-value] def extract_users(data: dict) -> dict: """ Pull the 'users' map out of parsed tfvars data. python-hcl2 sometimes wraps map literals in a list of single-key dicts; this normalises both representations to a plain {username: attrs} dict. """ raw = data.get("users", {}) if isinstance(raw, list): merged: dict = {} for item in raw: if isinstance(item, dict): merged.update(item) return merged return dict(raw) def _is_disabled(user: dict) -> bool: val = user.get("disabled") if val is None: return False if isinstance(val, bool): return val return str(val).lower() == "true" def _merge_roles(orchard_roles, delphi_roles) -> list: combined = set(orchard_roles or []) | set(delphi_roles or []) return sorted(combined) def sync_users(delphi: dict, orchard: dict) -> tuple[dict, dict]: """ Returns (updated_orchard_map, summary_dict). """ result = {name: dict(attrs) for name, attrs in orchard.items()} added: list[str] = [] roles_merged: list[str] = [] disabled_removed: list[str] = [] email_updated: list[tuple[str, str, str]] = [] # (name, old, new) comment_updated: list[tuple[str, str, str]] = [] # (name, old, new) rsa_key_copied: list[str] = [] rsa_key_2_copied: list[str] = [] for name, d_attrs in delphi.items(): if name not in result: result[name] = dict(d_attrs) added.append(name) continue o_attrs = result[name] # Delphi email takes priority d_email = d_attrs.get("email") o_email = o_attrs.get("email") if d_email and d_email != o_email: o_attrs["email"] = d_email email_updated.append((name, o_email or "", d_email)) # Delphi comment takes priority if explicitly set d_comment = d_attrs.get("comment") o_comment = o_attrs.get("comment") if d_comment and d_comment != o_comment: o_attrs["comment"] = d_comment comment_updated.append((name, o_comment or "", d_comment)) # RSA key logic d_rsa = d_attrs.get("rsa_public_key") o_rsa = o_attrs.get("rsa_public_key") if d_rsa: if not o_rsa: o_attrs["rsa_public_key"] = d_rsa rsa_key_copied.append(name) elif d_rsa != o_rsa and not o_attrs.get("rsa_public_key_2"): o_attrs["rsa_public_key_2"] = d_rsa rsa_key_2_copied.append(name) # Merge roles (union) merged = _merge_roles(o_attrs.get("roles", []), d_attrs.get("roles", [])) if set(merged) != set(o_attrs.get("roles") or []): roles_merged.append(name) o_attrs["roles"] = merged # Remove 'disabled' from Orchard if Delphi user is not disabled if not _is_disabled(d_attrs): if "disabled" in o_attrs: del o_attrs["disabled"] disabled_removed.append(name) summary = { "added": sorted(added), "roles_merged": sorted(roles_merged), "disabled_removed": sorted(disabled_removed), "email_updated": sorted(email_updated, key=lambda t: t[0]), "comment_updated": sorted(comment_updated, key=lambda t: t[0]), "rsa_key_copied": sorted(rsa_key_copied), "rsa_key_2_copied": sorted(rsa_key_2_copied), } return result, summary def _hcl_value(value, indent: int) -> str: """Render a Python value as an HCL literal.""" pad = " " * indent if value is None: return "null" if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (int, float)): return str(value) if isinstance(value, str): return f'"{value}"' if isinstance(value, list): if not value: return "[]" inner = " " * (indent + 1) items = "".join(f"\n{inner}{_hcl_value(v, indent + 1)}," for v in value) return f"[{items}\n{' ' * indent}]" if isinstance(value, dict): lines = ["{"] inner = " " * (indent + 1) for k, v in value.items(): lines.append(f'{inner}{k} = {_hcl_value(v, indent + 1)}') lines.append(f"{pad}}}") return "\n".join(lines) return f'"{value}"' def write_hcl(path: Path, users: dict) -> None: lines = ["users = {"] for username, attrs in sorted(users.items()): lines.append(f' "{username}" = {{') for key, val in attrs.items(): lines.append(f" {key} = {_hcl_value(val, 2)}") lines.append(" }") lines.append("}") lines.append("") path.write_text("\n".join(lines), encoding="utf-8") def _print_summary(summary: dict) -> None: sep = "=" * 64 print(f"\n{sep}") print("SYNC SUMMARY: Delphi → Orchard") print(sep) added = summary["added"] print(f"\nUSERS ADDED TO ORCHARD ({len(added)}):") if added: for name in added: print(f" + {name}") else: print(" (none)") merged = summary["roles_merged"] print(f"\nUSERS WITH ROLES MERGED ({len(merged)}):") if merged: for name in merged: print(f" ~ {name}") else: print(" (none)") removed = summary["disabled_removed"] print(f"\nUSERS WITH 'disabled' REMOVED ({len(removed)}):") if removed: for name in removed: print(f" - {name}") else: print(" (none)") email_updated = summary["email_updated"] print(f"\nUSERS WITH EMAIL UPDATED TO DELPHI VALUE ({len(email_updated)}):") if email_updated: for name, old, new in email_updated: print(f" ! {name}: {old!r} → {new!r}") else: print(" (none)") comment_updated = summary["comment_updated"] print(f"\nUSERS WITH COMMENT UPDATED TO DELPHI VALUE ({len(comment_updated)}):") if comment_updated: for name, old, new in comment_updated: print(f" ! {name}: {old!r} → {new!r}") else: print(" (none)") rsa_key_copied = summary["rsa_key_copied"] print(f"\nUSERS WITH RSA KEY COPIED FROM DELPHI ({len(rsa_key_copied)}):") if rsa_key_copied: for name in rsa_key_copied: print(f" + {name}") else: print(" (none)") rsa_key_2_copied = summary["rsa_key_2_copied"] print(f"\nUSERS WITH DELPHI RSA KEY COPIED TO rsa_public_key_2 ({len(rsa_key_2_copied)}):") if rsa_key_2_copied: for name in rsa_key_2_copied: print(f" + {name}") else: print(" (none)") print(f"\n{sep}\n") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--terraform-infra", required=True, metavar="PATH", help="Path to the terraform-infra repository root", ) parser.add_argument( "--dry-run", action="store_true", help="Print the sync summary without writing the output file", ) args = parser.parse_args() infra_root = Path(args.terraform_infra).expanduser().resolve() delphi_tfvars = infra_root / DELPHI_TFVARS_REL orchard_tfvars = infra_root / ORCHARD_TFVARS_REL output_path = infra_root / OUTPUT_REL for p in (delphi_tfvars, orchard_tfvars): if not p.exists(): sys.exit(f"File not found: {p}") print(f"Parsing Delphi tfvars: {delphi_tfvars}", flush=True) delphi_users = extract_users(load_tfvars(delphi_tfvars)) print(f" → {len(delphi_users):,} users found", flush=True) print(f"Parsing Orchard tfvars: {orchard_tfvars}", flush=True) orchard_users = extract_users(load_tfvars(orchard_tfvars)) print(f" → {len(orchard_users):,} users found", flush=True) updated_orchard, summary = sync_users(delphi_users, orchard_users) _print_summary(summary) if args.dry_run: print("Dry-run mode: no output written.") return write_hcl(output_path, updated_orchard) print(f"Output written to: {output_path}") if __name__ == "__main__": main()