""" Sync service users from Delphi Terraform config into Orchard Terraform config. Reads: /prod/snowflake/delphi/service_users/terraform.tfvars /prod/snowflake/orchard/service_users/terraform.tfvars Each file must contain two variables: 'service_users' and 'legacy_service_users', both maps of user objects. Each map is synced independently. Sync rules applied to each Orchard map: - Users only in Delphi → added to Orchard unchanged. - Users present in both accounts: * email : Delphi value takes priority if different. * comment : Delphi value takes priority if explicitly set. * roles : merged (union, alphabetically sorted). * disabled: removed from Orchard if Delphi user lacks it or has it False. * 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). * login : differences are reported but not changed. Output: /prod/snowflake/orchard/service_users/terraform_candidate.tfvars Usage: pip install python-hcl2 python3 sync-snowflake-service-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/service_users/terraform.tfvars" ORCHARD_TFVARS_REL = "prod/snowflake/orchard/service_users/terraform.tfvars" OUTPUT_REL = "prod/snowflake/orchard/service_users/generated.tfvars" USER_MAPS = ("legacy_service_users", "service_users") def _strip_quotes(value): """ python-hcl2 sometimes preserves the literal surrounding double-quotes from HCL string tokens (e.g. '"SVCACCT"' instead of 'SVCACCT'). 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_map(data: dict, var_name: str) -> dict: """ Pull a named map variable 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(var_name, {}) 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_map(delphi: dict, orchard: dict) -> tuple[dict, dict]: """ Sync one user map (delphi → orchard). 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] = [] login_mismatch: list[tuple[str, str, str]] = [] # (name, orchard_val, delphi_val) 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, sorted) 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) # Report login mismatches (no change made) d_login = d_attrs.get("login") o_login = o_attrs.get("login") if d_login and o_login and d_login != o_login: login_mismatch.append((name, o_login, d_login)) 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), "login_mismatch": sorted(login_mismatch, key=lambda t: t[0]), } 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{pad}]" 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, maps: dict[str, dict]) -> None: """Write one or more user maps to a .tfvars file.""" blocks: list[str] = [] for var_name, users in maps.items(): lines = [f"{var_name} = {{"] 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("}") blocks.append("\n".join(lines)) path.write_text("\n\n".join(blocks) + "\n", encoding="utf-8") def _print_map_summary(label: str, summary: dict) -> None: sep = "-" * 56 print(f"\n {label}") print(f" {sep}") added = summary["added"] print(f" USERS ADDED TO ORCHARD ({len(added)}):") if added: for name in added: print(f" + {name}") else: print(" (none)") merged = summary["roles_merged"] print(f" USERS WITH ROLES MERGED ({len(merged)}):") if merged: for name in merged: print(f" ~ {name}") else: print(" (none)") removed = summary["disabled_removed"] print(f" USERS WITH 'disabled' REMOVED ({len(removed)}):") if removed: for name in removed: print(f" - {name}") else: print(" (none)") email_updated = summary["email_updated"] print(f" USERS 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" USERS 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_copied = summary["rsa_key_copied"] print(f" USERS WITH RSA KEY COPIED FROM DELPHI ({len(rsa_copied)}):") if rsa_copied: for name in rsa_copied: print(f" + {name}") else: print(" (none)") rsa2_copied = summary["rsa_key_2_copied"] print(f" USERS WITH DELPHI RSA KEY COPIED TO rsa_public_key_2 ({len(rsa2_copied)}):") if rsa2_copied: for name in rsa2_copied: print(f" + {name}") else: print(" (none)") login_mismatch = summary["login_mismatch"] print(f" *** LOGIN MISMATCHES — MANUAL REVIEW REQUIRED ({len(login_mismatch)}) ***") if login_mismatch: for name, o_login, d_login in login_mismatch: print(f" ! {name}: orchard={o_login!r} delphi={d_login!r}") else: print(" (none)") def _print_summary( summaries: dict[str, dict], redirected_to_service: list[str], redirected_to_legacy: list[str], ) -> None: sep = "=" * 64 print(f"\n{sep}") print("SYNC SUMMARY: Delphi → Orchard (service users)") print(sep) print(f"\n CROSS-MAP REDIRECTS: delphi legacy → orchard service_users ({len(redirected_to_service)}):") if redirected_to_service: for name in redirected_to_service: print(f" ↳ {name}") else: print(" (none)") print(f"\n CROSS-MAP REDIRECTS: delphi service → orchard legacy_service_users ({len(redirected_to_legacy)}):") if redirected_to_legacy: for name in redirected_to_legacy: print(f" ↳ {name}") else: print(" (none)") for var_name, summary in summaries.items(): _print_map_summary(var_name, summary) 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_data = load_tfvars(delphi_tfvars) print(f"Parsing Orchard tfvars: {orchard_tfvars}", flush=True) orchard_data = load_tfvars(orchard_tfvars) delphi_legacy = extract_map(delphi_data, "legacy_service_users") delphi_service = extract_map(delphi_data, "service_users") orchard_legacy = extract_map(orchard_data, "legacy_service_users") orchard_service = extract_map(orchard_data, "service_users") # Delphi legacy → Orchard service: treat as service redirected_to_service = sorted( name for name in delphi_legacy if name in orchard_service and name not in orchard_legacy ) # Delphi service → Orchard legacy: treat as legacy redirected_to_legacy = sorted( name for name in delphi_service if name in orchard_legacy and name not in delphi_legacy ) delphi_legacy_final = { **{n: a for n, a in delphi_legacy.items() if n not in redirected_to_service}, **{n: delphi_service[n] for n in redirected_to_legacy}, } delphi_service_final = { **{n: a for n, a in delphi_service.items() if n not in redirected_to_legacy}, **{n: delphi_legacy[n] for n in redirected_to_service}, } print( f" legacy_service_users: {len(delphi_legacy):,} delphi" f" ({len(redirected_to_service)} redirected to service_users," f" {len(redirected_to_legacy)} redirected in from service_users)" f" / {len(orchard_legacy):,} orchard", flush=True, ) print( f" service_users: {len(delphi_service):,} delphi" f" ({len(redirected_to_legacy)} redirected to legacy_service_users," f" {len(redirected_to_service)} redirected in from legacy_service_users)" f" / {len(orchard_service):,} orchard", flush=True, ) updated_maps: dict[str, dict] = {} summaries: dict[str, dict] = {} updated_maps["legacy_service_users"], summaries["legacy_service_users"] = sync_map( delphi_legacy_final, orchard_legacy ) updated_maps["service_users"], summaries["service_users"] = sync_map( delphi_service_final, orchard_service ) _print_summary(summaries, redirected_to_service, redirected_to_legacy) if args.dry_run: print("Dry-run mode: no output written.") return write_hcl(output_path, updated_maps) print(f"Output written to: {output_path}") if __name__ == "__main__": main()