@@ -128,6 +128,9 @@ The benchmark command:
|
||||
- Run the benchmark on the target environment and database profile before deciding defaults:
|
||||
- `vctp -settings /path/to/vctp.yml -benchmark-aggregations -benchmark-runs 3`
|
||||
- Current local comparison snapshot (2026-04-20) is recorded in `phase-metrics-2026-04-20.md`.
|
||||
- Latest tuned Postgres snapshot (2026-04-21, `runs=3`) showed:
|
||||
- Daily window (`2026-04-21` to `2026-04-22` UTC): Go avg `2.261369712s` vs SQL avg `1m31.738727387s` (Go ~`40.57x` faster).
|
||||
- Monthly window (`2026-04-01` to `2026-05-01` UTC): Go avg `3.705308832s` vs SQL avg `3.065612298s` (SQL ~`1.21x` faster).
|
||||
- Default-path decision remains `settings.scheduled_aggregation_engine: go`.
|
||||
- Promote SQL only when representative production-scale **Postgres** runs show clear, repeatable wins.
|
||||
|
||||
|
||||
+113
-35
@@ -107,13 +107,24 @@ func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, user
|
||||
Username: username,
|
||||
UserDN: username,
|
||||
}
|
||||
if whoami, err := conn.WhoAmI(nil); err != nil {
|
||||
identity.Diagnostics = append(identity.Diagnostics, fmt.Sprintf("whoami_failed:%v", err))
|
||||
} else if boundDN := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(whoami.AuthzID), "dn:")); boundDN != "" {
|
||||
identity.UserDN = boundDN
|
||||
identity.Diagnostics = append(identity.Diagnostics, "whoami_dn_resolved")
|
||||
} else {
|
||||
identity.Diagnostics = append(identity.Diagnostics, "whoami_dn_empty")
|
||||
}
|
||||
|
||||
entry, err := a.lookupUserEntry(conn, username)
|
||||
entry, lookupStrategy, err := a.lookupUserEntry(conn, username, identity.UserDN)
|
||||
if err != nil {
|
||||
return LDAPIdentity{}, err
|
||||
}
|
||||
if entry != nil {
|
||||
identity.Diagnostics = append(identity.Diagnostics, "user_entry_found")
|
||||
if lookupStrategy == "" {
|
||||
lookupStrategy = "unknown"
|
||||
}
|
||||
identity.Diagnostics = append(identity.Diagnostics, "user_entry_found:"+lookupStrategy)
|
||||
if strings.TrimSpace(entry.DN) != "" {
|
||||
identity.UserDN = entry.DN
|
||||
}
|
||||
@@ -140,6 +151,7 @@ func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, user
|
||||
}
|
||||
}
|
||||
|
||||
groupFilter := buildGroupMembershipFilter(identity.UserDN, principalCandidates(username))
|
||||
groupEntries, err := conn.Search(ldap.NewSearchRequest(
|
||||
a.baseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
@@ -147,11 +159,7 @@ func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, user
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
fmt.Sprintf("(|(member=%s)(uniqueMember=%s)(memberUid=%s))",
|
||||
ldap.EscapeFilter(identity.UserDN),
|
||||
ldap.EscapeFilter(identity.UserDN),
|
||||
ldap.EscapeFilter(username),
|
||||
),
|
||||
groupFilter,
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
))
|
||||
@@ -272,10 +280,24 @@ func (a *LDAPAuthenticator) buildTLSConfig() (*tls.Config, error) {
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
func (a *LDAPAuthenticator) lookupUserEntry(conn *ldap.Conn, username string) (*ldap.Entry, error) {
|
||||
func (a *LDAPAuthenticator) lookupUserEntry(conn *ldap.Conn, username string, userDNHint string) (*ldap.Entry, string, error) {
|
||||
dnCandidates := compactTrimmedStrings([]string{userDNHint})
|
||||
if looksLikeDN(username) {
|
||||
dnCandidates = append(dnCandidates, strings.TrimSpace(username))
|
||||
}
|
||||
seenDN := make(map[string]struct{}, len(dnCandidates))
|
||||
for _, dn := range dnCandidates {
|
||||
key := normalizeDN(dn)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenDN[key]; ok {
|
||||
continue
|
||||
}
|
||||
seenDN[key] = struct{}{}
|
||||
|
||||
searchRes, err := conn.Search(ldap.NewSearchRequest(
|
||||
username,
|
||||
dn,
|
||||
ldap.ScopeBaseObject,
|
||||
ldap.NeverDerefAliases,
|
||||
1,
|
||||
@@ -286,37 +308,41 @@ func (a *LDAPAuthenticator) lookupUserEntry(conn *ldap.Conn, username string) (*
|
||||
nil,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: unable to load user entry: %v", ErrLDAPOperationFailed, err)
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||
continue
|
||||
}
|
||||
return nil, "", fmt.Errorf("%w: unable to load user entry by dn: %v", ErrLDAPOperationFailed, err)
|
||||
}
|
||||
if len(searchRes.Entries) == 0 {
|
||||
return nil, nil
|
||||
if len(searchRes.Entries) > 0 {
|
||||
return searchRes.Entries[0], "dn", nil
|
||||
}
|
||||
return searchRes.Entries[0], nil
|
||||
}
|
||||
|
||||
searchRes, err := conn.Search(ldap.NewSearchRequest(
|
||||
a.baseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
2,
|
||||
0,
|
||||
false,
|
||||
fmt.Sprintf("(|(uid=%s)(cn=%s)(sAMAccountName=%s)(userPrincipalName=%s))",
|
||||
ldap.EscapeFilter(username),
|
||||
ldap.EscapeFilter(username),
|
||||
ldap.EscapeFilter(username),
|
||||
ldap.EscapeFilter(username),
|
||||
),
|
||||
[]string{"uid", "sAMAccountName", "userPrincipalName", "cn", "memberOf"},
|
||||
nil,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: user lookup failed: %v", ErrLDAPOperationFailed, err)
|
||||
for _, principal := range principalCandidates(username) {
|
||||
searchRes, err := conn.Search(ldap.NewSearchRequest(
|
||||
a.baseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
2,
|
||||
0,
|
||||
false,
|
||||
fmt.Sprintf("(|(uid=%s)(cn=%s)(sAMAccountName=%s)(userPrincipalName=%s))",
|
||||
ldap.EscapeFilter(principal),
|
||||
ldap.EscapeFilter(principal),
|
||||
ldap.EscapeFilter(principal),
|
||||
ldap.EscapeFilter(principal),
|
||||
),
|
||||
[]string{"uid", "sAMAccountName", "userPrincipalName", "cn", "memberOf"},
|
||||
nil,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: user lookup failed: %v", ErrLDAPOperationFailed, err)
|
||||
}
|
||||
if len(searchRes.Entries) > 0 {
|
||||
return searchRes.Entries[0], "principal", nil
|
||||
}
|
||||
}
|
||||
if len(searchRes.Entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return searchRes.Entries[0], nil
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
func normalizeDN(value string) string {
|
||||
@@ -352,6 +378,58 @@ func looksLikeDN(value string) bool {
|
||||
return strings.Contains(value, "=") && strings.Contains(value, ",")
|
||||
}
|
||||
|
||||
func principalCandidates(username string) []string {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, 4)
|
||||
candidates := make([]string, 0, 4)
|
||||
add := func(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(value)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
candidates = append(candidates, value)
|
||||
}
|
||||
|
||||
add(username)
|
||||
if idx := strings.LastIndex(username, `\`); idx >= 0 && idx < len(username)-1 {
|
||||
add(username[idx+1:])
|
||||
}
|
||||
if idx := strings.Index(username, "@"); idx > 0 {
|
||||
add(username[:idx])
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
func buildGroupMembershipFilter(userDN string, principals []string) string {
|
||||
clauses := make([]string, 0, 2+len(principals))
|
||||
userDN = strings.TrimSpace(userDN)
|
||||
if userDN != "" {
|
||||
escapedDN := ldap.EscapeFilter(userDN)
|
||||
clauses = append(clauses, "(member="+escapedDN+")", "(uniqueMember="+escapedDN+")")
|
||||
}
|
||||
for _, principal := range principals {
|
||||
principal = strings.TrimSpace(principal)
|
||||
if principal == "" {
|
||||
continue
|
||||
}
|
||||
clauses = append(clauses, "(memberUid="+ldap.EscapeFilter(principal)+")")
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
return "(objectClass=group)"
|
||||
}
|
||||
return "(|" + strings.Join(clauses, "") + ")"
|
||||
}
|
||||
|
||||
func ctxErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
|
||||
@@ -37,3 +37,52 @@ func TestHasAnyGroup(t *testing.T) {
|
||||
t.Fatal("expected empty required groups to allow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipalCandidates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "upn adds local part",
|
||||
username: "L075239@corpau.wbcau.westpac.com.au",
|
||||
want: []string{"L075239@corpau.wbcau.westpac.com.au", "L075239"},
|
||||
},
|
||||
{
|
||||
name: "domain slash user adds sam",
|
||||
username: `CORPAU\L075239`,
|
||||
want: []string{`CORPAU\L075239`, "L075239"},
|
||||
},
|
||||
{
|
||||
name: "plain username unchanged",
|
||||
username: "L075239",
|
||||
want: []string{"L075239"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := principalCandidates(tc.username)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("unexpected candidate count: got=%d want=%d (%#v)", len(got), len(tc.want), got)
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("unexpected candidate at %d: got=%q want=%q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGroupMembershipFilter(t *testing.T) {
|
||||
filter := buildGroupMembershipFilter(
|
||||
"CN=User,OU=Users,DC=corpau,DC=wbcau,DC=westpac,DC=com,DC=au",
|
||||
[]string{"L075239@corpau.wbcau.westpac.com.au", "L075239"},
|
||||
)
|
||||
expected := "(|(member=CN=User,OU=Users,DC=corpau,DC=wbcau,DC=westpac,DC=com,DC=au)(uniqueMember=CN=User,OU=Users,DC=corpau,DC=wbcau,DC=westpac,DC=com,DC=au)(memberUid=L075239@corpau.wbcau.westpac.com.au)(memberUid=L075239))"
|
||||
if filter != expected {
|
||||
t.Fatalf("unexpected group filter:\n got: %s\nwant: %s", filter, expected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,10 +305,15 @@ The target architecture is:
|
||||
- [x] Validate/add canonical `vm_hourly_stats` indexes for snapshot time, vCenter+time, VM identity+time, and trace lookup.
|
||||
- [x] Add PostgreSQL monthly partitioning for `vm_hourly_stats` behind migration controls.
|
||||
- [x] Benchmark Go vs SQL on canonical Postgres tables using representative production-scale data.
|
||||
- Production-scale Postgres run completed on 2026-04-21 via one-shot canonical benchmark (`-benchmark-aggregations` with `runs_per_mode=1`, `driver=postgres`).
|
||||
- Daily window `2026-04-20T00:00:00Z` to `2026-04-21T00:00:00Z`: Go `4.000602432s` (`14881` rows) vs SQL `1h17m19.039092561s` (`14920` rows), with Go ~`1159.59x` faster on this run.
|
||||
- Monthly window `2026-04-01T00:00:00Z` to `2026-05-01T00:00:00Z`: Go `3.529410947s` (`15871` rows) vs SQL `3.313037973s` (`15873` rows), near parity with SQL slightly faster (~`0.216s`, `6.1%`).
|
||||
- Decision remains unchanged: keep Go as scheduled default and treat SQL as fallback/backfill until SQL shows a clear, repeatable runtime win across canonical workloads.
|
||||
- Production-scale Postgres benchmark runs completed on 2026-04-21 via one-shot canonical benchmark (`-benchmark-aggregations`, `driver=postgres`, with `runs_per_mode=1` and `runs_per_mode=3`).
|
||||
- Run A (pre-tuning), daily window `2026-04-20T00:00:00Z` to `2026-04-21T00:00:00Z`: Go `4.000602432s` (`14881` rows) vs SQL `1h17m19.039092561s` (`14920` rows), with Go ~`1159.59x` faster.
|
||||
- Run A (pre-tuning), monthly window `2026-04-01T00:00:00Z` to `2026-05-01T00:00:00Z`: Go `3.529410947s` (`15871` rows) vs SQL `3.313037973s` (`15873` rows), near parity with SQL slightly faster (~`0.216s`, `6.1%`).
|
||||
- Run B (after PostgreSQL tuning), daily window `2026-04-21T00:00:00Z` to `2026-04-22T00:00:00Z`: Go `2.277889486s` (`14831` rows) vs SQL `1m31.273491543s` (`14839` rows), with Go still ~`40.07x` faster.
|
||||
- Run B (after PostgreSQL tuning), monthly window `2026-04-01T00:00:00Z` to `2026-05-01T00:00:00Z`: Go `3.947474215s` (`15871` rows) vs SQL `2.758716002s` (`15873` rows), with SQL ~`1.43x` faster.
|
||||
- Run C (after PostgreSQL tuning, `runs=3`), daily window `2026-04-21T00:00:00Z` to `2026-04-22T00:00:00Z`: Go avg `2.261369712s` (min `2.169537168s`, median `2.191474445s`, max `2.423097524s`, rows `14831`) vs SQL avg `1m31.738727387s` (min `1m29.960115863s`, median `1m32.068576507s`, max `1m33.187489791s`, rows `14839`), with Go ~`40.57x` faster by average.
|
||||
- Run C (after PostgreSQL tuning, `runs=3`), monthly window `2026-04-01T00:00:00Z` to `2026-05-01T00:00:00Z`: Go avg `3.705308832s` (min `3.696553751s`, median `3.70776704s`, max `3.711605706s`, rows `15871`) vs SQL avg `3.065612298s` (min `2.873749798s`, median `3.022090149s`, max `3.300996948s`, rows `15873`), with SQL ~`1.21x` faster by average (~`17.26%` faster than Go).
|
||||
- Tuning impact between Run A and Run B: daily SQL improved ~`50.83x`, daily Go improved ~`1.76x`, monthly SQL improved ~`1.20x`, and monthly Go regressed (~`0.89x` of prior speed).
|
||||
- Decision remains unchanged: keep Go as scheduled default and treat SQL as fallback/backfill until SQL shows a clear, repeatable runtime win across canonical workloads, especially on daily windows (where Go remains consistently dominant across runs).
|
||||
- [x] Keep Go as scheduled default unless SQL shows clear and repeatable runtime wins.
|
||||
- [x] If SQL wins, roll out behind a controlled flag before any default switch.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user