From aecdfc43b57413f15723488a28b2265d78c8b6f0 Mon Sep 17 00:00:00 2001 From: Jerry Combs Date: Thu, 8 Oct 2026 16:13:58 -0500 Subject: [PATCH] schema.diff: the tree's schema against an environment's live one, or two environments' Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 8 + docs/schema.md | 17 ++ .../java/com/pointblue/dirxml/dev/Cli.java | 2 +- .../dirxml/dev/deploy/DeployCli.java | 16 ++ .../dirxml/dev/deploy/SchemaDiff.java | 257 ++++++++++++++++++ .../dirxml/dev/deploy/SchemaDiffTest.java | 81 ++++++ 6 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/pointblue/dirxml/dev/deploy/SchemaDiff.java create mode 100644 src/test/java/com/pointblue/dirxml/dev/deploy/SchemaDiffTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index a9182fd..2901954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Added + +- **`schema.diff`** (docs/schema.md): the tree's `schema/vault.xml` against an environment's live + `cn=schema` (`schema.diff --env E`), or two environments' live schemas (`--env A --other + B`); nothing written. Definitions match by NDS or LDAP name; only-here, only-there, and + differing syntax, OID, flags, superclasses or attribute lists (as sets) are reported, custom + definitions not yet pushed marked. `SchemaDiff.of` for the web's Compare. + ## [0.16.0] - 2026-10-08 ### Added diff --git a/docs/schema.md b/docs/schema.md index dc3b282..da14db1 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -39,6 +39,23 @@ A push needs an OID on each definition (eDirectory requires one over LDAP; use y organisation's arc), writes attributes first and classes in superclass order, and skips what the vault already has. +## Comparing — `schema.diff` + +Schema is per environment: each vault has its own, and the tree's copy is one of them at one +moment. Nothing here writes anything. + +```bash +idm schema.diff tree/ --env stg # schema/vault.xml against stg's live cn=schema +idm schema.diff --env stg --other prd # two environments' live schemas +idm schema.diff tree/ --env prd --json +``` + +Definitions match by NDS name, else by LDAP name, case-insensitively. A difference is a definition +only one side has (`<` left, `>` right), or one both have with another syntax, OID, flag, +superclass, containment, naming or attribute list (`~`, with each field named; lists compare as +sets). A field neither side states (Designer's copy carries no OIDs) is not a difference. A +custom definition the tree has not pushed yet is marked so. Exit status 0 means in sync. + ## The application's schema — `drivers//app-schema.xml` ```bash diff --git a/src/main/java/com/pointblue/dirxml/dev/Cli.java b/src/main/java/com/pointblue/dirxml/dev/Cli.java index 9c9a125..19ab033 100644 --- a/src/main/java/com/pointblue/dirxml/dev/Cli.java +++ b/src/main/java/com/pointblue/dirxml/dev/Cli.java @@ -303,7 +303,7 @@ public static void main(String[] args) { System.out.print(json ? rep.json() + "\n" : rep.text()); System.exit(rep.ok() ? 0 : 1); } - if (args.length >= 2 && args[0].startsWith("vault.")) { + if (args.length >= 2 && (args[0].startsWith("vault.") || args[0].equals("schema.diff"))) { System.exit(com.pointblue.dirxml.dev.deploy.DeployCli.run(args)); } if (args.length >= 2 && args[0].equals("package.status")) { diff --git a/src/main/java/com/pointblue/dirxml/dev/deploy/DeployCli.java b/src/main/java/com/pointblue/dirxml/dev/deploy/DeployCli.java index 5ebc5e4..1e3833e 100644 --- a/src/main/java/com/pointblue/dirxml/dev/deploy/DeployCli.java +++ b/src/main/java/com/pointblue/dirxml/dev/deploy/DeployCli.java @@ -123,6 +123,22 @@ public static int run(String[] argv) throws Exception { System.out.print(json ? r.json() + "\n" : r.text()); return r.ok ? 0 : 1; } + case "schema.diff": { + // the tree's schema/vault.xml against the environment's live cn=schema, or two environments' (--other) + if (pos.isEmpty() && first(opts, "other") == null) { + System.err.println("usage: schema.diff --env [--json] | schema.diff --env --other [--json]"); + return 2; + } + SchemaDiff.Result r; + String other = first(opts, "other"); + if (other != null) { + r = SchemaDiff.liveVsLive(env, Environments.load().get(other)); + } else { + r = SchemaDiff.modelVsLive(Paths.get(pos.get(0)), env); + } + System.out.print(json ? r.json() + "\n" : r.text()); + return r.inSync() ? 0 : 1; + } case "vault.rollback": { String snap = first(opts, "snapshot"); if (snap == null) { diff --git a/src/main/java/com/pointblue/dirxml/dev/deploy/SchemaDiff.java b/src/main/java/com/pointblue/dirxml/dev/deploy/SchemaDiff.java new file mode 100644 index 0000000..2f575e1 --- /dev/null +++ b/src/main/java/com/pointblue/dirxml/dev/deploy/SchemaDiff.java @@ -0,0 +1,257 @@ +package com.pointblue.dirxml.dev.deploy; + +import com.pointblue.dirxml.dev.clone.Schema; +import com.pointblue.dirxml.dev.json.Json; +import com.pointblue.dirxml.dev.ascode.AsCodeReader; +import com.pointblue.dirxml.dev.model.DriverSet; +import com.pointblue.dirxml.dev.model.VaultSchema; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeSet; + +/** + * Two schemas side by side, nothing written (docs/schema.md): the tree's {@code schema/vault.xml} + * against an environment's live {@code cn=schema}, or two environments' live schemas. Definitions + * match by NDS name, else by LDAP name, case-insensitively; a definition in one side only, or in + * both with a different syntax, flag, superclass or attribute list, is a change. + */ +public final class SchemaDiff { + + /** One difference. */ + public static final class Change { + public final String kind; // attribute | class + public final String name; + public final String where; // left-only | right-only | differs + public final List fields = new ArrayList<>(); // for differs: "syntax: a → b" + public boolean custom; // defined in the tree, not yet pushed (left = model only) + + Change(String kind, String name, String where) { + this.kind = kind; + this.name = name; + this.where = where; + } + } + + public static final class Result { + public final String left; + public final String right; + public final List changes = new ArrayList<>(); + public int attributesCompared; + public int classesCompared; + + Result(String left, String right) { + this.left = left; + this.right = right; + } + + public boolean inSync() { + return changes.isEmpty(); + } + + public Map toMap() { + Map m = new LinkedHashMap<>(); + m.put("left", left); + m.put("right", right); + m.put("inSync", inSync()); + m.put("attributesCompared", attributesCompared); + m.put("classesCompared", classesCompared); + Map counts = new LinkedHashMap<>(); + for (Change c : changes) { + counts.merge(c.kind + ":" + c.where, 1, Integer::sum); + } + m.put("counts", counts); + List list = new ArrayList<>(); + for (Change c : changes) { + Map cm = new LinkedHashMap<>(); + cm.put("kind", c.kind); + cm.put("name", c.name); + cm.put("where", c.where); + cm.put("fields", c.fields); + cm.put("custom", c.custom); + list.add(cm); + } + m.put("changes", list); + return m; + } + + public String json() { + return Json.pretty(toMap()); + } + + public String text() { + StringBuilder sb = new StringBuilder(); + sb.append(left).append(" vs ").append(right).append(": ") + .append(inSync() ? "in sync" : changes.size() + " difference(s)") + .append(" (").append(attributesCompared).append(" attributes, ").append(classesCompared).append(" classes compared)\n"); + for (Change c : changes) { + String mark = c.where.equals("left-only") ? "<" : c.where.equals("right-only") ? ">" : "~"; + sb.append(" ").append(mark).append(' ').append(c.kind).append(' ').append(c.name); + if (c.custom) { + sb.append(" (custom, not pushed)"); + } + sb.append('\n'); + for (String f : c.fields) { + sb.append(" ").append(f).append('\n'); + } + } + return sb.toString(); + } + } + + private SchemaDiff() { + } + + /** The environment's live schema, read from {@code cn=schema}. */ + public static VaultSchema live(Environments.Environment env) throws IOException { + try (Vault v = Vault.connect(env.vaultConfig())) { + Vault.Entry e = v.read("cn=schema", "attributeTypes", "objectClasses"); + if (e == null) { + throw new IOException("cn=schema is not readable on " + env.url); + } + return VaultSchema.fromLdap(Schema.of(e.strings("attributeTypes"), e.strings("objectClasses")), env.url); + } + } + + /** The tree's {@code schema/vault.xml} (left) against the environment's live schema (right). */ + public static Result modelVsLive(Path tree, Environments.Environment env) throws IOException { + DriverSet ds = AsCodeReader.read(tree); + if (ds.schema == null) { + throw new IOException("the tree has no schema/vault.xml yet: run vault.schema --env " + env.name + " first"); + } + return of(ds.schema, live(env), "model", env.name); + } + + /** Two environments' live schemas. */ + public static Result liveVsLive(Environments.Environment a, Environments.Environment b) throws IOException { + return of(live(a), live(b), a.name, b.name); + } + + public static Result of(VaultSchema left, VaultSchema right, String leftName, String rightName) { + Result r = new Result(leftName, rightName); + Map ra = new LinkedHashMap<>(); + for (VaultSchema.AttrDef a : right.attributes) { + ra.put(key(a.name), a); + if (a.ldap != null) { + ra.putIfAbsent(key(a.ldap), a); + } + } + java.util.Set matched = new java.util.HashSet<>(); + for (VaultSchema.AttrDef a : left.attributes) { + VaultSchema.AttrDef b = ra.get(key(a.name)); + if (b == null && a.ldap != null) { + b = ra.get(key(a.ldap)); + } + if (b == null) { + Change c = new Change("attribute", a.name, "left-only"); + c.custom = a.custom; + r.changes.add(c); + continue; + } + matched.add(b); + r.attributesCompared++; + List fields = new ArrayList<>(); + field(fields, "ldap", a.ldap, b.ldap); + field(fields, "oid", a.oid, b.oid); + field(fields, "syntax", a.syntax, b.syntax); + field(fields, "single-valued", a.single, b.single); + field(fields, "no-user-modification", a.noUserModification, b.noUserModification); + if (!fields.isEmpty()) { + Change c = new Change("attribute", a.name, "differs"); + c.fields.addAll(fields); + r.changes.add(c); + } + } + for (VaultSchema.AttrDef b : right.attributes) { + if (!matched.contains(b)) { + r.changes.add(new Change("attribute", b.name, "right-only")); + } + } + Map rc = new LinkedHashMap<>(); + for (VaultSchema.ClassDef c : right.classes) { + rc.put(key(c.name), c); + if (c.ldap != null) { + rc.putIfAbsent(key(c.ldap), c); + } + } + java.util.Set matchedC = new java.util.HashSet<>(); + for (VaultSchema.ClassDef a : left.classes) { + VaultSchema.ClassDef b = rc.get(key(a.name)); + if (b == null && a.ldap != null) { + b = rc.get(key(a.ldap)); + } + if (b == null) { + Change c = new Change("class", a.name, "left-only"); + c.custom = a.custom; + r.changes.add(c); + continue; + } + matchedC.add(b); + r.classesCompared++; + List fields = new ArrayList<>(); + field(fields, "ldap", a.ldap, b.ldap); + field(fields, "oid", a.oid, b.oid); + field(fields, "kind", a.kind, b.kind); + field(fields, "container", a.container, b.container); + set(fields, "superclasses", a.superclasses, b.superclasses); + set(fields, "mandatory", a.mandatory, b.mandatory); + set(fields, "optional", a.optional, b.optional); + set(fields, "containment", a.containment, b.containment); + set(fields, "naming", a.naming, b.naming); + if (!fields.isEmpty()) { + Change c = new Change("class", a.name, "differs"); + c.fields.addAll(fields); + r.changes.add(c); + } + } + for (VaultSchema.ClassDef b : right.classes) { + if (!matchedC.contains(b)) { + r.changes.add(new Change("class", b.name, "right-only")); + } + } + return r; + } + + private static String key(String name) { + return name == null ? "" : name.trim().toLowerCase(Locale.ROOT); + } + + private static void field(List out, String what, Object a, Object b) { + String sa = a == null ? "" : String.valueOf(a).trim(); + String sb = b == null ? "" : String.valueOf(b).trim(); + if (sa.isEmpty() && sb.isEmpty()) { + return; // neither side says: nothing to compare (Designer's copy may lack an OID) + } + if (!Objects.equals(sa, sb)) { + out.add(what + ": " + (sa.isEmpty() ? "—" : sa) + " → " + (sb.isEmpty() ? "—" : sb)); + } + } + + /** Lists compared as sets of names, case-insensitively; the names only one side has are shown. */ + private static void set(List out, String what, List a, List b) { + TreeSet sa = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + TreeSet sb = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + sa.addAll(a); + sb.addAll(b); + List onlyA = new ArrayList<>(); + List onlyB = new ArrayList<>(); + for (String s : sa) { + if (!sb.contains(s)) { + onlyA.add(s); + } + } + for (String s : sb) { + if (!sa.contains(s)) { + onlyB.add(s); + } + } + if (!onlyA.isEmpty() || !onlyB.isEmpty()) { + out.add(what + ": " + (onlyA.isEmpty() ? "" : "left has " + String.join(", ", onlyA)) + (onlyA.isEmpty() || onlyB.isEmpty() ? "" : "; ") + (onlyB.isEmpty() ? "" : "right has " + String.join(", ", onlyB))); + } + } +} diff --git a/src/test/java/com/pointblue/dirxml/dev/deploy/SchemaDiffTest.java b/src/test/java/com/pointblue/dirxml/dev/deploy/SchemaDiffTest.java new file mode 100644 index 0000000..bbd48cd --- /dev/null +++ b/src/test/java/com/pointblue/dirxml/dev/deploy/SchemaDiffTest.java @@ -0,0 +1,81 @@ +package com.pointblue.dirxml.dev.deploy; + +import com.pointblue.dirxml.dev.model.VaultSchema; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** Two schemas side by side: what counts as a difference, and what does not. */ +public class SchemaDiffTest { + + private static VaultSchema.AttrDef attr(String name, String ldap, String syntax, boolean single) { + VaultSchema.AttrDef a = new VaultSchema.AttrDef(name, ldap); + a.syntax = syntax; + a.single = single; + return a; + } + + private static VaultSchema.ClassDef cls(String name, String ldap, String... optional) { + VaultSchema.ClassDef c = new VaultSchema.ClassDef(name, ldap); + c.superclasses.add("Top"); + c.optional.addAll(List.of(optional)); + return c; + } + + @Test + public void onlyRealDifferencesCount() { + VaultSchema model = new VaultSchema(); + model.add(attr("Given Name", "givenName", "1.3.6.1.4.1.1466.115.121.1.15", false)); + model.add(attr("PB Cost Center", "pbCostCenter", "1.3.6.1.4.1.1466.115.121.1.15", true)); + model.attribute("PB Cost Center").custom = true; + model.add(attr("Title", "title", "1.3.6.1.4.1.1466.115.121.1.15", false)); + model.add(cls("User", "inetOrgPerson", "Given Name", "Title")); + model.add(cls("pbContractor", "pbContractor", "PB Cost Center")); + model.classDef("pbContractor").custom = true; + + VaultSchema live = new VaultSchema(); + live.add(attr("given name", "givenName", "1.3.6.1.4.1.1466.115.121.1.15", false)); // case differs: same thing + live.add(attr("Title", "title", "1.3.6.1.4.1.1466.115.121.1.15", true)); // single-valued there + live.add(attr("Surname", "sn", "1.3.6.1.4.1.1466.115.121.1.15", false)); // only in the vault + live.add(cls("User", "inetOrgPerson", "Title", "Given Name", "Surname")); // order differs, one more + + SchemaDiff.Result r = SchemaDiff.of(model, live, "model", "lab"); + assertFalse(r.inSync()); + assertEquals(2, r.attributesCompared); + assertEquals(1, r.classesCompared); + Map m = r.toMap(); + @SuppressWarnings("unchecked") Map counts = (Map) m.get("counts"); + assertEquals(Integer.valueOf(1), counts.get("attribute:left-only")); + assertEquals(Integer.valueOf(1), counts.get("attribute:right-only")); + assertEquals(Integer.valueOf(1), counts.get("attribute:differs")); + assertEquals(Integer.valueOf(1), counts.get("class:left-only")); + assertEquals(Integer.valueOf(1), counts.get("class:differs")); + SchemaDiff.Change costCenter = r.changes.stream().filter(c -> c.name.equals("PB Cost Center")).findFirst().orElseThrow(); + assertTrue("a custom definition not yet pushed says so", costCenter.custom && costCenter.where.equals("left-only")); + SchemaDiff.Change title = r.changes.stream().filter(c -> c.name.equals("Title")).findFirst().orElseThrow(); + assertEquals(List.of("single-valued: false → true"), title.fields); + SchemaDiff.Change user = r.changes.stream().filter(c -> c.name.equals("User")).findFirst().orElseThrow(); + assertEquals(List.of("optional: right has Surname"), user.fields); + assertTrue(r.text().contains("< attribute PB Cost Center (custom, not pushed)")); + assertTrue(r.text().contains("> attribute Surname")); + } + + @Test + public void identicalSchemasAreInSyncAndAMissingOidOnOneSideIsNotADifference() { + VaultSchema a = new VaultSchema(); + a.add(attr("Title", "title", "1.3.6.1.4.1.1466.115.121.1.15", false)); + VaultSchema b = new VaultSchema(); + b.add(attr("Title", "title", "1.3.6.1.4.1.1466.115.121.1.15", false)); + b.attribute("Title").oid = "2.5.4.12"; + a.attribute("Title").oid = null; + SchemaDiff.Result r = SchemaDiff.of(a, b, "stg", "prd"); + assertEquals("oid: — → 2.5.4.12", r.changes.get(0).fields.get(0)); + b.attribute("Title").oid = null; + assertTrue(SchemaDiff.of(a, b, "stg", "prd").inSync()); + } +}