Compare commits
10 Commits
27cab61e89
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| fb7e9bdca4 | |||
| 35840697fa | |||
| 4fca10795e | |||
| 14d242c8d1 | |||
| a8e38784d9 | |||
| d2a7145a4c | |||
| 4b1b985862 | |||
| 361ba7719b | |||
| 2c3167a1a0 | |||
| 916b0b5054 |
@@ -124,6 +124,16 @@ The benchmark command:
|
||||
- Runs Go and SQL aggregation cores for the latest available daily/monthly windows.
|
||||
- Writes results to startup logs and exits without changing scheduled defaults.
|
||||
|
||||
### Benchmark method and decision record
|
||||
- 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.
|
||||
|
||||
## Database Configuration
|
||||
By default the app uses SQLite and creates/opens `db.sqlite3`.
|
||||
|
||||
@@ -204,6 +214,80 @@ Validate connectivity before starting vCTP:
|
||||
psql "postgres://vctp_user:change-this-password@db-hostname:5432/vctp?sslmode=disable"
|
||||
```
|
||||
|
||||
### PostgreSQL tuning baseline (20 vCPU / 64 GB host)
|
||||
If your PostgreSQL instance is still running near-default settings, use this as a practical starting profile for vCTP workloads (hourly ingest + daily/monthly aggregation).
|
||||
|
||||
Choose one profile:
|
||||
- Dedicated DB host (PostgreSQL is the primary service on this machine): use the `dedicated` values.
|
||||
- Shared host (vCTP app + PostgreSQL on same machine): use the `shared` values.
|
||||
|
||||
Recommended `postgresql.conf` starting points:
|
||||
|
||||
```conf
|
||||
# Memory
|
||||
shared_buffers = 16GB # dedicated
|
||||
# shared_buffers = 12GB # shared
|
||||
effective_cache_size = 48GB # dedicated
|
||||
# effective_cache_size = 36GB # shared
|
||||
work_mem = 32MB # dedicated
|
||||
# work_mem = 16MB # shared
|
||||
maintenance_work_mem = 2GB # dedicated
|
||||
# maintenance_work_mem = 1GB # shared
|
||||
|
||||
# WAL / checkpoints
|
||||
wal_compression = on
|
||||
checkpoint_timeout = 15min
|
||||
checkpoint_completion_target = 0.9
|
||||
max_wal_size = 16GB
|
||||
min_wal_size = 2GB
|
||||
|
||||
# Parallelism and connections
|
||||
max_connections = 120
|
||||
max_worker_processes = 20
|
||||
max_parallel_workers = 20
|
||||
max_parallel_workers_per_gather = 4
|
||||
max_parallel_maintenance_workers = 4
|
||||
|
||||
# Planner / IO (SSD/NVMe)
|
||||
random_page_cost = 1.1
|
||||
effective_io_concurrency = 200
|
||||
default_statistics_target = 200
|
||||
|
||||
# Autovacuum for high-write canonical tables
|
||||
autovacuum_max_workers = 6
|
||||
autovacuum_naptime = 30s
|
||||
autovacuum_vacuum_scale_factor = 0.02
|
||||
autovacuum_analyze_scale_factor = 0.01
|
||||
autovacuum_vacuum_cost_limit = 2000
|
||||
|
||||
# Useful diagnostics
|
||||
track_io_timing = on
|
||||
log_temp_files = 32MB
|
||||
```
|
||||
|
||||
Apply and validate:
|
||||
- Reload config (`SELECT pg_reload_conf();`) or restart PostgreSQL if required by your platform.
|
||||
- Confirm active values with:
|
||||
|
||||
```sql
|
||||
SHOW shared_buffers;
|
||||
SHOW effective_cache_size;
|
||||
SHOW work_mem;
|
||||
SHOW maintenance_work_mem;
|
||||
SHOW max_wal_size;
|
||||
SHOW autovacuum_vacuum_scale_factor;
|
||||
```
|
||||
|
||||
After tuning, rerun the canonical benchmark and compare against your pre-tuning snapshot:
|
||||
|
||||
```shell
|
||||
vctp -settings /path/to/vctp.yml -benchmark-aggregations -benchmark-runs 3
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `work_mem` is per sort/hash operation, not per session; avoid setting it too high globally.
|
||||
- Keep `settings.scheduled_aggregation_engine: go` as default unless repeated production-scale benchmarks show SQL is consistently faster on your canonical Postgres data.
|
||||
|
||||
PostgreSQL migrations live in `db/migrations_postgres`, while SQLite migrations remain in
|
||||
`db/migrations`.
|
||||
|
||||
@@ -269,6 +353,8 @@ settings:
|
||||
auth_mode: required
|
||||
ldap_bind_address: ldaps://ad01.example.com:636
|
||||
ldap_base_dn: DC=example,DC=com
|
||||
# Optional user lookup scope; defaults to ldap_base_dn when omitted.
|
||||
ldap_user_base_dn: OU=Users,DC=example,DC=com
|
||||
auth_group_role_mappings:
|
||||
"CN=vctp-viewers,OU=Groups,DC=example,DC=com": viewer
|
||||
"CN=vctp-admins,OU=Groups,DC=example,DC=com": admin
|
||||
@@ -351,6 +437,44 @@ These endpoints are considered legacy and are disabled by default unless `settin
|
||||
|
||||
When disabled, they return HTTP `410 Gone` with JSON error payload.
|
||||
|
||||
## Compatibility mode lifecycle (`snapshot_table_compat_mode`)
|
||||
- Default is `true` during migration phases.
|
||||
- `true`: scheduled hourly capture continues writing legacy `inventory_hourly_*` outputs in addition to canonical tables.
|
||||
- `false`: scheduled hourly capture writes canonical hourly cache and lifecycle/totals caches only.
|
||||
- Disable criteria:
|
||||
- parity/integration/compatibility test gates are passing
|
||||
- baseline-vs-post-change metrics comparison is recorded and accepted
|
||||
- repair/backfill workflows are validated in the target environment
|
||||
- Rollback to legacy hourly output is immediate: set `snapshot_table_compat_mode: true` and restart the service.
|
||||
- Compatibility repair/backfill workflows remain available through:
|
||||
- `POST /api/snapshots/aggregate`
|
||||
- `POST /api/snapshots/repair`
|
||||
- `POST /api/snapshots/repair/all`
|
||||
- `POST /api/snapshots/regenerate-hourly-reports`
|
||||
- `POST /api/vcenters/cache/rebuild`
|
||||
- `vctp -settings /path/to/vctp.yml -backfill-vcenter-cache`
|
||||
|
||||
## Migration runbook (staged rollout, rollback, repair)
|
||||
1. Baseline: capture current metrics/state (`phase0-baseline.md` style snapshot) and verify auth/report contracts.
|
||||
2. Enable canonical runtime settings (already defaulted): `capture_write_batch_size: 1000`, `snapshot_table_compat_mode: true`, `async_report_generation: true`, `scheduled_aggregation_engine: go`.
|
||||
3. Deploy and monitor: review `/metrics`, `snapshot_runs`, `cron_status`, and generated reports for at least one full hourly/daily cycle.
|
||||
4. Validate canonicity gates: run parity/integration/compatibility suites and compare baseline vs post-change metrics.
|
||||
5. Optional compatibility reduction: set `snapshot_table_compat_mode: false` only after step 4 passes and repair workflows are validated.
|
||||
6. SQL default switch gate: only evaluate after production-scale Postgres benchmark evidence; otherwise keep `scheduled_aggregation_engine: go`.
|
||||
|
||||
Rollback triggers:
|
||||
- sustained increase in `vctp_*_failed_total` metrics
|
||||
- missing/stale summary tables or report outputs
|
||||
- material mismatch between totals endpoints and expected aggregates
|
||||
- repeated job timeout or cron failure indicators
|
||||
|
||||
Rollback actions:
|
||||
1. Set `scheduled_aggregation_engine: go` (if changed) and restart.
|
||||
2. Set `snapshot_table_compat_mode: true` and restart.
|
||||
3. Run `POST /api/snapshots/repair/all`.
|
||||
4. Run `POST /api/snapshots/regenerate-hourly-reports` and/or `-backfill-vcenter-cache` as needed.
|
||||
5. Re-check `/metrics`, `snapshot_runs`, and endpoint/report correctness before closing the incident.
|
||||
|
||||
## Settings Reference
|
||||
All configuration lives under the top-level `settings:` key in `vctp.yml`.
|
||||
|
||||
@@ -388,7 +512,8 @@ Authentication:
|
||||
- A user must belong to at least one mapped group to receive any role and log in.
|
||||
- `settings.ldap_groups` empty/omitted means no allowlist filter, but mapped-role requirement still applies.
|
||||
- `settings.ldap_bind_address`: LDAP/LDAPS URL used for authentication.
|
||||
- `settings.ldap_base_dn`: LDAP base DN for user/group lookups.
|
||||
- `settings.ldap_base_dn`: LDAP base DN fallback used for user lookup when `settings.ldap_user_base_dn` is not set.
|
||||
- `settings.ldap_user_base_dn`: optional user lookup base DN; defaults to `settings.ldap_base_dn`.
|
||||
- `settings.ldap_trust_cert_file`: optional CA cert file for LDAP TLS.
|
||||
- `settings.ldap_disable_validation`: disables LDAP TLS cert validation.
|
||||
- `settings.ldap_insecure`: insecure LDAP TLS mode.
|
||||
@@ -417,6 +542,9 @@ Snapshots:
|
||||
- `settings.hourly_index_max_age_days`: age gate for keeping per-hourly-table indexes (`-1` disables cleanup, `0` trims all)
|
||||
- `settings.snapshot_cleanup_cron`: cron expression for cleanup job
|
||||
- `settings.reports_dir`: directory to store generated XLSX reports (default: `/var/lib/vctp/reports`)
|
||||
- `settings.capture_write_batch_size`: hourly canonical write batch size (default: `1000`)
|
||||
- `settings.snapshot_table_compat_mode`: keep writing legacy hourly snapshot tables during migration (default: `true`)
|
||||
- `settings.async_report_generation`: defer report generation from the hourly capture hot path (default: `true`)
|
||||
- `settings.report_summary_pivots`: optional list to override Summary worksheet pivot titles/names/ranges in daily/monthly XLSX reports
|
||||
- `metric`: one of `avg_vcpu`, `avg_ram`, `prorated_vm_count`, `vm_name_count`
|
||||
- `title`: pivot title text shown on Summary sheet
|
||||
|
||||
@@ -473,7 +473,7 @@ func VcenterTotalsPage(vcenter string, entries []VcenterTotalsEntry, chart Vcent
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"></canvas><div id=\"vcenter-totals-tooltip\" class=\"web3-chart-tooltip\" aria-hidden=\"true\"></div></div><script>\n\t\t\t\t\t\t\t\twindow.Web3Charts.renderFromDataset({\n\t\t\t\t\t\t\t\t\tcanvasId: \"vcenter-totals-chart\",\n\t\t\t\t\t\t\t\t\ttooltipId: \"vcenter-totals-tooltip\",\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t</script></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"></canvas><div id=\"vcenter-totals-tooltip\" class=\"web3-chart-tooltip\" aria-hidden=\"true\"></div></div><script>\r\n\t\t\t\t\t\t\t\twindow.Web3Charts.renderFromDataset({\r\n\t\t\t\t\t\t\t\t\tcanvasId: \"vcenter-totals-chart\",\r\n\t\t\t\t\t\t\t\t\ttooltipId: \"vcenter-totals-tooltip\",\r\n\t\t\t\t\t\t\t\t})\r\n\t\t\t\t\t\t\t</script></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"></canvas><div id=\"vm-trace-tooltip\" class=\"web3-chart-tooltip\" aria-hidden=\"true\"></div></div><script>\n\t\t\t\t\t\t\t\twindow.Web3Charts.renderFromDataset({\n\t\t\t\t\t\t\t\t\tcanvasId: \"vm-trace-chart\",\n\t\t\t\t\t\t\t\t\ttooltipId: \"vm-trace-tooltip\",\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t</script></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"></canvas><div id=\"vm-trace-tooltip\" class=\"web3-chart-tooltip\" aria-hidden=\"true\"></div></div><script>\r\n\t\t\t\t\t\t\t\twindow.Web3Charts.renderFromDataset({\r\n\t\t\t\t\t\t\t\t\tcanvasId: \"vm-trace-chart\",\r\n\t\t\t\t\t\t\t\t\ttooltipId: \"vm-trace-tooltip\",\r\n\t\t\t\t\t\t\t\t})\r\n\t\t\t\t\t\t\t</script></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
Vendored
-17
@@ -364,15 +364,6 @@ body {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.web2-button-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.web2-button-group .web2-button {
|
||||
margin: 0 0.5rem 0.5rem 0;
|
||||
}
|
||||
|
||||
.web3-button {
|
||||
background: var(--theme_surface_primary);
|
||||
color: var(--theme_text_primary);
|
||||
@@ -418,14 +409,6 @@ body {
|
||||
box-shadow: var(--theme_shadow_table_inset);
|
||||
}
|
||||
|
||||
.web2-list li {
|
||||
background: var(--theme_surface_primary);
|
||||
border: 1px solid var(--theme_border);
|
||||
border-radius: var(--theme_radius_card);
|
||||
padding: 0.75rem 1rem;
|
||||
box-shadow: var(--theme_shadow_card);
|
||||
}
|
||||
|
||||
.web2-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -104,7 +104,8 @@ func (s *JWTService) IssueToken(subject string, roles []string, groups []string)
|
||||
claims := Claims{
|
||||
Subject: subject,
|
||||
Roles: compactTrimmedStrings(roles),
|
||||
Groups: compactTrimmedStrings(groups),
|
||||
// Intentionally omit LDAP groups from JWTs; role claims are sufficient for authorization.
|
||||
Groups: nil,
|
||||
Issuer: s.issuer,
|
||||
Audience: s.audience,
|
||||
IssuedAt: now.Unix(),
|
||||
|
||||
@@ -57,6 +57,21 @@ func TestIssueAndVerifyTokenRoundTrip(t *testing.T) {
|
||||
if issuedClaims.ID == "" {
|
||||
t.Fatal("expected jti to be populated")
|
||||
}
|
||||
if len(issuedClaims.Groups) != 0 {
|
||||
t.Fatalf("expected groups to be omitted from issued claims, got %#v", issuedClaims.Groups)
|
||||
}
|
||||
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("expected jwt to have 3 parts, got %d", len(parts))
|
||||
}
|
||||
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode jwt payload: %v", err)
|
||||
}
|
||||
if strings.Contains(string(payloadJSON), `"groups"`) {
|
||||
t.Fatalf("expected jwt payload to omit groups claim, got payload: %s", string(payloadJSON))
|
||||
}
|
||||
|
||||
verifiedClaims, err := svc.VerifyToken(token)
|
||||
if err != nil {
|
||||
|
||||
+245
-45
@@ -25,6 +25,7 @@ var (
|
||||
type LDAPConfig struct {
|
||||
BindAddress string
|
||||
BaseDN string
|
||||
UserBaseDN string
|
||||
TrustCertFile string
|
||||
DisableValidation bool
|
||||
Insecure bool
|
||||
@@ -35,11 +36,17 @@ type LDAPIdentity struct {
|
||||
Username string
|
||||
UserDN string
|
||||
Groups []string
|
||||
BindDuration time.Duration
|
||||
UserLookupDuration time.Duration
|
||||
GroupMembershipLookupDuration time.Duration
|
||||
// Diagnostics contains non-sensitive LDAP processing notes useful for debugging auth decisions.
|
||||
Diagnostics []string
|
||||
}
|
||||
|
||||
type LDAPAuthenticator struct {
|
||||
bindAddress string
|
||||
baseDN string
|
||||
userBaseDN string
|
||||
trustCertFile string
|
||||
disableValidation bool
|
||||
insecure bool
|
||||
@@ -49,6 +56,7 @@ type LDAPAuthenticator struct {
|
||||
func NewLDAPAuthenticator(cfg LDAPConfig) (*LDAPAuthenticator, error) {
|
||||
bindAddress := strings.TrimSpace(cfg.BindAddress)
|
||||
baseDN := strings.TrimSpace(cfg.BaseDN)
|
||||
userBaseDN := strings.TrimSpace(cfg.UserBaseDN)
|
||||
trustCertFile := strings.TrimSpace(cfg.TrustCertFile)
|
||||
|
||||
if bindAddress == "" {
|
||||
@@ -57,6 +65,9 @@ func NewLDAPAuthenticator(cfg LDAPConfig) (*LDAPAuthenticator, error) {
|
||||
if baseDN == "" {
|
||||
return nil, fmt.Errorf("%w: base DN is required", ErrInvalidLDAPConfig)
|
||||
}
|
||||
if userBaseDN == "" {
|
||||
userBaseDN = baseDN
|
||||
}
|
||||
if _, err := url.ParseRequestURI(bindAddress); err != nil {
|
||||
return nil, fmt.Errorf("%w: bind address must be a valid URL: %v", ErrInvalidLDAPConfig, err)
|
||||
}
|
||||
@@ -69,6 +80,7 @@ func NewLDAPAuthenticator(cfg LDAPConfig) (*LDAPAuthenticator, error) {
|
||||
return &LDAPAuthenticator{
|
||||
bindAddress: bindAddress,
|
||||
baseDN: baseDN,
|
||||
userBaseDN: userBaseDN,
|
||||
trustCertFile: trustCertFile,
|
||||
disableValidation: cfg.DisableValidation,
|
||||
insecure: cfg.Insecure,
|
||||
@@ -77,13 +89,14 @@ func NewLDAPAuthenticator(cfg LDAPConfig) (*LDAPAuthenticator, error) {
|
||||
}
|
||||
|
||||
func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, username string, password string) (LDAPIdentity, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" || password == "" {
|
||||
inputUsername := strings.TrimSpace(username)
|
||||
if inputUsername == "" || password == "" {
|
||||
return LDAPIdentity{}, ErrLDAPInvalidCredentials
|
||||
}
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return LDAPIdentity{}, err
|
||||
}
|
||||
bindUsername, rewrittenToUPN := normalizeBindUsername(inputUsername, a.baseDN)
|
||||
|
||||
conn, err := a.connect()
|
||||
if err != nil {
|
||||
@@ -91,26 +104,54 @@ func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, user
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.Bind(username, password); err != nil {
|
||||
bindStartedAt := time.Now()
|
||||
err = conn.Bind(bindUsername, password)
|
||||
bindDuration := time.Since(bindStartedAt)
|
||||
if err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
|
||||
return LDAPIdentity{}, ErrLDAPInvalidCredentials
|
||||
return LDAPIdentity{}, fmt.Errorf("%w: ldap bind rejected credentials (bind_duration=%s)", ErrLDAPInvalidCredentials, bindDuration)
|
||||
}
|
||||
return LDAPIdentity{}, fmt.Errorf("%w: bind failed: %v", ErrLDAPOperationFailed, err)
|
||||
return LDAPIdentity{}, fmt.Errorf("%w: bind failed: %v (bind_duration=%s)", ErrLDAPOperationFailed, err, bindDuration)
|
||||
}
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return LDAPIdentity{}, err
|
||||
}
|
||||
|
||||
identity := LDAPIdentity{
|
||||
Username: username,
|
||||
UserDN: username,
|
||||
Username: inputUsername,
|
||||
UserDN: bindUsername,
|
||||
BindDuration: bindDuration,
|
||||
}
|
||||
identity.Diagnostics = append(identity.Diagnostics, fmt.Sprintf("bind_duration_ms=%d", bindDuration.Milliseconds()))
|
||||
if rewrittenToUPN {
|
||||
identity.Diagnostics = append(identity.Diagnostics, "bind_username_rewritten_to_upn")
|
||||
}
|
||||
identity.Diagnostics = append(identity.Diagnostics,
|
||||
"user_lookup_base_dn="+a.userBaseDN,
|
||||
)
|
||||
if whoami, err := conn.WhoAmI(nil); err != nil {
|
||||
identity.Diagnostics = append(identity.Diagnostics, fmt.Sprintf("whoami_failed:%v", err))
|
||||
} else if boundDN := parseWhoAmIDN(whoami.AuthzID); boundDN != "" {
|
||||
identity.UserDN = boundDN
|
||||
identity.Diagnostics = append(identity.Diagnostics, "whoami_dn_resolved")
|
||||
} else if strings.TrimSpace(whoami.AuthzID) == "" {
|
||||
identity.Diagnostics = append(identity.Diagnostics, "whoami_dn_empty")
|
||||
} else {
|
||||
identity.Diagnostics = append(identity.Diagnostics, "whoami_non_dn_authzid")
|
||||
}
|
||||
|
||||
entry, err := a.lookupUserEntry(conn, username)
|
||||
userLookupStartedAt := time.Now()
|
||||
entry, lookupStrategy, err := a.lookupUserEntry(conn, inputUsername, identity.UserDN)
|
||||
identity.UserLookupDuration = time.Since(userLookupStartedAt)
|
||||
identity.Diagnostics = append(identity.Diagnostics, fmt.Sprintf("user_lookup_duration_ms=%d", identity.UserLookupDuration.Milliseconds()))
|
||||
if err != nil {
|
||||
return LDAPIdentity{}, err
|
||||
return LDAPIdentity{}, fmt.Errorf("%w: %v (bind_duration=%s user_lookup_duration=%s)", ErrLDAPOperationFailed, err, identity.BindDuration, identity.UserLookupDuration)
|
||||
}
|
||||
if entry != nil {
|
||||
if lookupStrategy == "" {
|
||||
lookupStrategy = "unknown"
|
||||
}
|
||||
identity.Diagnostics = append(identity.Diagnostics, "user_entry_found:"+lookupStrategy)
|
||||
if strings.TrimSpace(entry.DN) != "" {
|
||||
identity.UserDN = entry.DN
|
||||
}
|
||||
@@ -122,9 +163,12 @@ func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, user
|
||||
); v != "" {
|
||||
identity.Username = v
|
||||
}
|
||||
} else {
|
||||
identity.Diagnostics = append(identity.Diagnostics, "user_entry_not_found")
|
||||
}
|
||||
|
||||
groupSet := make(map[string]struct{})
|
||||
groupLookupStartedAt := time.Now()
|
||||
if entry != nil {
|
||||
for _, groupDN := range entry.GetAttributeValues("memberOf") {
|
||||
groupDN = strings.TrimSpace(groupDN)
|
||||
@@ -135,30 +179,14 @@ func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, user
|
||||
}
|
||||
}
|
||||
|
||||
groupEntries, err := conn.Search(ldap.NewSearchRequest(
|
||||
a.baseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
fmt.Sprintf("(|(member=%s)(uniqueMember=%s)(memberUid=%s))",
|
||||
ldap.EscapeFilter(identity.UserDN),
|
||||
ldap.EscapeFilter(identity.UserDN),
|
||||
ldap.EscapeFilter(username),
|
||||
),
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
))
|
||||
if err == nil {
|
||||
for _, e := range groupEntries.Entries {
|
||||
if dn := strings.TrimSpace(e.DN); dn != "" {
|
||||
groupSet[dn] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Intentionally skip subtree group membership search for now.
|
||||
// Authorization is based only on direct group membership values present in the user entry (memberOf).
|
||||
identity.GroupMembershipLookupDuration = time.Since(groupLookupStartedAt)
|
||||
identity.Diagnostics = append(identity.Diagnostics, fmt.Sprintf("group_lookup_duration_ms=%d", identity.GroupMembershipLookupDuration.Milliseconds()))
|
||||
identity.Diagnostics = append(identity.Diagnostics, "group_search_skipped_direct_memberof_only")
|
||||
|
||||
identity.Groups = mapKeysSorted(groupSet)
|
||||
identity.Diagnostics = compactTrimmedStrings(identity.Diagnostics)
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
@@ -261,10 +289,27 @@ 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 := make([]string, 0, 2)
|
||||
if looksLikeDN(userDNHint) {
|
||||
dnCandidates = append(dnCandidates, strings.TrimSpace(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,
|
||||
@@ -275,32 +320,70 @@ 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
|
||||
}
|
||||
if len(searchRes.Entries) == 0 {
|
||||
return nil, "", fmt.Errorf("%w: unable to load user entry by dn: %v", ErrLDAPOperationFailed, err)
|
||||
}
|
||||
if len(searchRes.Entries) > 0 {
|
||||
return searchRes.Entries[0], "dn", nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, principal := range principalCandidates(username) {
|
||||
if strings.Contains(principal, "@") {
|
||||
entry, err := a.searchUserByAttribute(conn, "userPrincipalName", principal)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if entry != nil {
|
||||
return entry, "principal_upn", nil
|
||||
}
|
||||
// For UPN principals, avoid fallback attribute probes that are unlikely to match
|
||||
// and can be expensive on large directory trees.
|
||||
continue
|
||||
}
|
||||
|
||||
entry, err := a.searchUserByAttribute(conn, "sAMAccountName", principal)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if entry != nil {
|
||||
return entry, "principal_samaccountname", nil
|
||||
}
|
||||
|
||||
// Keep uid lookup as a fallback for non-AD LDAP directories.
|
||||
entry, err = a.searchUserByAttribute(conn, "uid", principal)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if entry != nil {
|
||||
return entry, "principal_uid", nil
|
||||
}
|
||||
}
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
func (a *LDAPAuthenticator) searchUserByAttribute(conn *ldap.Conn, attribute string, value string) (*ldap.Entry, error) {
|
||||
attribute = strings.TrimSpace(attribute)
|
||||
value = strings.TrimSpace(value)
|
||||
if attribute == "" || value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return searchRes.Entries[0], nil
|
||||
}
|
||||
|
||||
searchRes, err := conn.Search(ldap.NewSearchRequest(
|
||||
a.baseDN,
|
||||
a.userBaseDN,
|
||||
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),
|
||||
),
|
||||
fmt.Sprintf("(%s=%s)", attribute, ldap.EscapeFilter(value)),
|
||||
[]string{"uid", "sAMAccountName", "userPrincipalName", "cn", "memberOf"},
|
||||
nil,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: user lookup failed: %v", ErrLDAPOperationFailed, err)
|
||||
return nil, fmt.Errorf("%w: user lookup failed (%s): %v", ErrLDAPOperationFailed, attribute, err)
|
||||
}
|
||||
if len(searchRes.Entries) == 0 {
|
||||
return nil, nil
|
||||
@@ -341,6 +424,123 @@ func looksLikeDN(value string) bool {
|
||||
return strings.Contains(value, "=") && strings.Contains(value, ",")
|
||||
}
|
||||
|
||||
func parseWhoAmIDN(authzID string) string {
|
||||
authzID = strings.TrimSpace(authzID)
|
||||
if authzID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lower := strings.ToLower(authzID)
|
||||
if strings.HasPrefix(lower, "dn:") {
|
||||
authzID = strings.TrimSpace(authzID[3:])
|
||||
}
|
||||
if !looksLikeDN(authzID) {
|
||||
return ""
|
||||
}
|
||||
return authzID
|
||||
}
|
||||
|
||||
func normalizeBindUsername(username string, baseDN string) (string, bool) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return "", false
|
||||
}
|
||||
if looksLikeDN(username) || strings.Contains(username, "@") {
|
||||
return username, false
|
||||
}
|
||||
|
||||
// Convert DOMAIN\user to user before UPN rewrite.
|
||||
if idx := strings.LastIndex(username, `\`); idx >= 0 && idx < len(username)-1 {
|
||||
username = strings.TrimSpace(username[idx+1:])
|
||||
}
|
||||
|
||||
domain := upnDomainFromBaseDN(baseDN)
|
||||
if domain == "" {
|
||||
return username, false
|
||||
}
|
||||
if strings.Contains(username, "@") {
|
||||
return username, false
|
||||
}
|
||||
return username + "@" + domain, true
|
||||
}
|
||||
|
||||
func upnDomainFromBaseDN(baseDN string) string {
|
||||
baseDN = strings.TrimSpace(baseDN)
|
||||
if baseDN == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.Split(baseDN, ",")
|
||||
labels := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if len(part) < 3 || !strings.EqualFold(part[:3], "dc=") {
|
||||
continue
|
||||
}
|
||||
label := strings.TrimSpace(part[3:])
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
labels = append(labels, label)
|
||||
}
|
||||
if len(labels) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(labels, ".")
|
||||
}
|
||||
|
||||
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,174 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWhoAmIDN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authzID string
|
||||
wantDN string
|
||||
}{
|
||||
{
|
||||
name: "dn prefix",
|
||||
authzID: "dn:CN=User,OU=Users,DC=corpau,DC=wbcau,DC=westpac,DC=com,DC=au",
|
||||
wantDN: "CN=User,OU=Users,DC=corpau,DC=wbcau,DC=westpac,DC=com,DC=au",
|
||||
},
|
||||
{
|
||||
name: "dn prefix upper",
|
||||
authzID: "DN:CN=User,OU=Users,DC=corpau,DC=wbcau,DC=westpac,DC=com,DC=au",
|
||||
wantDN: "CN=User,OU=Users,DC=corpau,DC=wbcau,DC=westpac,DC=com,DC=au",
|
||||
},
|
||||
{
|
||||
name: "non dn authzid",
|
||||
authzID: "u:L075239@corpau.wbcau.westpac.com.au",
|
||||
wantDN: "",
|
||||
},
|
||||
{
|
||||
name: "plain non dn",
|
||||
authzID: "L075239@corpau.wbcau.westpac.com.au",
|
||||
wantDN: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := parseWhoAmIDN(tc.authzID)
|
||||
if got != tc.wantDN {
|
||||
t.Fatalf("unexpected whoami dn parse: got=%q want=%q", got, tc.wantDN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUPNDomainFromBaseDN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseDN string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "standard dc chain",
|
||||
baseDN: "dc=corpau,dc=wbcau,dc=westpac,dc=com,dc=au",
|
||||
want: "corpau.wbcau.westpac.com.au",
|
||||
},
|
||||
{
|
||||
name: "mixed dn parts",
|
||||
baseDN: "ou=Users,dc=example,dc=com",
|
||||
want: "example.com",
|
||||
},
|
||||
{
|
||||
name: "no dc parts",
|
||||
baseDN: "ou=Users,ou=Org",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := upnDomainFromBaseDN(tc.baseDN)
|
||||
if got != tc.want {
|
||||
t.Fatalf("unexpected upn domain from base dn: got=%q want=%q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBindUsername(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
baseDN string
|
||||
wantUser string
|
||||
wantRewrite bool
|
||||
}{
|
||||
{
|
||||
name: "plain sam rewritten",
|
||||
username: "L075239",
|
||||
baseDN: "dc=corpau,dc=wbcau,dc=westpac,dc=com,dc=au",
|
||||
wantUser: "L075239@corpau.wbcau.westpac.com.au",
|
||||
wantRewrite: true,
|
||||
},
|
||||
{
|
||||
name: "domain user rewritten",
|
||||
username: `CORPAU\L075239`,
|
||||
baseDN: "dc=corpau,dc=wbcau,dc=westpac,dc=com,dc=au",
|
||||
wantUser: "L075239@corpau.wbcau.westpac.com.au",
|
||||
wantRewrite: true,
|
||||
},
|
||||
{
|
||||
name: "upn unchanged",
|
||||
username: "L075239@corpau.wbcau.westpac.com.au",
|
||||
baseDN: "dc=corpau,dc=wbcau,dc=westpac,dc=com,dc=au",
|
||||
wantUser: "L075239@corpau.wbcau.westpac.com.au",
|
||||
wantRewrite: false,
|
||||
},
|
||||
{
|
||||
name: "dn unchanged",
|
||||
username: "CN=User,OU=Users,DC=corpau,DC=wbcau,DC=westpac,DC=com,DC=au",
|
||||
baseDN: "dc=corpau,dc=wbcau,dc=westpac,dc=com,dc=au",
|
||||
wantUser: "CN=User,OU=Users,DC=corpau,DC=wbcau,DC=westpac,DC=com,DC=au",
|
||||
wantRewrite: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotUser, gotRewrite := normalizeBindUsername(tc.username, tc.baseDN)
|
||||
if gotUser != tc.wantUser {
|
||||
t.Fatalf("unexpected normalized bind username: got=%q want=%q", gotUser, tc.wantUser)
|
||||
}
|
||||
if gotRewrite != tc.wantRewrite {
|
||||
t.Fatalf("unexpected rewrite flag: got=%v want=%v", gotRewrite, tc.wantRewrite)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ type SettingsYML struct {
|
||||
LDAPGroups []string `yaml:"ldap_groups"`
|
||||
LDAPBindAddress string `yaml:"ldap_bind_address"`
|
||||
LDAPBaseDN string `yaml:"ldap_base_dn"`
|
||||
LDAPUserBaseDN string `yaml:"ldap_user_base_dn"`
|
||||
LDAPTrustCertFile string `yaml:"ldap_trust_cert_file"`
|
||||
LDAPDisableValidation bool `yaml:"ldap_disable_validation"`
|
||||
LDAPInsecure bool `yaml:"ldap_insecure"`
|
||||
@@ -284,6 +285,7 @@ func applyDefaultsAndValidateSettings(cfg *SettingsYML) error {
|
||||
s.AuthJWTSigningKey = strings.TrimSpace(s.AuthJWTSigningKey)
|
||||
s.LDAPBindAddress = strings.TrimSpace(s.LDAPBindAddress)
|
||||
s.LDAPBaseDN = strings.TrimSpace(s.LDAPBaseDN)
|
||||
s.LDAPUserBaseDN = strings.TrimSpace(s.LDAPUserBaseDN)
|
||||
s.LDAPTrustCertFile = strings.TrimSpace(s.LDAPTrustCertFile)
|
||||
s.LDAPGroups = compactTrimmedStrings(s.LDAPGroups)
|
||||
|
||||
@@ -340,6 +342,9 @@ func applyDefaultsAndValidateSettings(cfg *SettingsYML) error {
|
||||
if s.LDAPBaseDN == "" {
|
||||
return errors.New("settings.ldap_base_dn is required when settings.auth_enabled=true")
|
||||
}
|
||||
if s.LDAPUserBaseDN == "" {
|
||||
s.LDAPUserBaseDN = s.LDAPBaseDN
|
||||
}
|
||||
if len(s.AuthGroupRoleMappings) == 0 {
|
||||
return errors.New("settings.auth_group_role_mappings must define at least one mapping when settings.auth_enabled=true")
|
||||
}
|
||||
|
||||
@@ -193,6 +193,9 @@ func TestReadYMLSettingsAcceptsValidAuthConfigAndNormalizesMappings(t *testing.T
|
||||
if len(got.LDAPGroups) != 1 || got.LDAPGroups[0] != "cn=vctp-viewers,ou=groups,dc=example,dc=com" {
|
||||
t.Fatalf("expected ldap_groups to be compacted+trimmed, got %#v", got.LDAPGroups)
|
||||
}
|
||||
if got.LDAPUserBaseDN != "dc=example,dc=com" {
|
||||
t.Fatalf("expected default ldap_user_base_dn to fall back to ldap_base_dn, got %q", got.LDAPUserBaseDN)
|
||||
}
|
||||
if got.AuthGroupRoleMappings["cn=vctp-admins,ou=groups,dc=example,dc=com"] != authRoleAdmin {
|
||||
t.Fatalf("expected admin mapping to normalize role to %q, got %#v", authRoleAdmin, got.AuthGroupRoleMappings)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
"vctp/db"
|
||||
"vctp/internal/settings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func TestCanonicalDailyFlow_WritesRollupAndTotalsCache(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dbConn := newTasksTestDB(t)
|
||||
task := newTasksTestCronTask(dbConn)
|
||||
|
||||
if err := db.EnsureVmHourlyStats(ctx, dbConn); err != nil {
|
||||
t.Fatalf("failed to ensure vm_hourly_stats: %v", err)
|
||||
}
|
||||
|
||||
dayStart := time.Date(2026, time.March, 10, 0, 0, 0, 0, time.UTC)
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
t1 := dayStart.Add(1 * time.Hour).Unix()
|
||||
t2 := dayStart.Add(2 * time.Hour).Unix()
|
||||
t3 := dayStart.Add(3 * time.Hour).Unix()
|
||||
|
||||
seeds := []hourlySeedRow{
|
||||
{SnapshotTime: t1, Name: "vm-a1", Vcenter: "vc-a", VmID: "vm-a1", VmUUID: "uuid-a1", ResourcePool: "Tin", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 100, VcpuCount: 2, RamGB: 8, CreationTime: dayStart.Add(-1 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t2, Name: "vm-a1", Vcenter: "vc-a", VmID: "vm-a1", VmUUID: "uuid-a1", ResourcePool: "Gold", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 120, VcpuCount: 4, RamGB: 8, CreationTime: dayStart.Add(-1 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t2, Name: "vm-a2", Vcenter: "vc-a", VmID: "vm-a2", VmUUID: "uuid-a2", ResourcePool: "Bronze", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 40, VcpuCount: 1, RamGB: 4, CreationTime: dayStart.Add(-2 * time.Hour).Unix(), DeletionTime: dayStart.Add(4 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t1, Name: "vm-b1", Vcenter: "vc-b", VmID: "vm-b1", VmUUID: "uuid-b1", ResourcePool: "Silver", Datacenter: "dc-b", Cluster: "cluster-b", Folder: "/prod", ProvisionedDisk: 200, VcpuCount: 8, RamGB: 32, CreationTime: dayStart.Add(-3 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t2, Name: "vm-b1", Vcenter: "vc-b", VmID: "vm-b1", VmUUID: "uuid-b1", ResourcePool: "Silver", Datacenter: "dc-b", Cluster: "cluster-b", Folder: "/prod", ProvisionedDisk: 200, VcpuCount: 8, RamGB: 32, CreationTime: dayStart.Add(-3 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t3, Name: "vm-b1", Vcenter: "vc-b", VmID: "vm-b1", VmUUID: "uuid-b1", ResourcePool: "Silver", Datacenter: "dc-b", Cluster: "cluster-b", Folder: "/prod", ProvisionedDisk: 200, VcpuCount: 8, RamGB: 32, CreationTime: dayStart.Add(-3 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
if err := insertHourlyCacheSeedRow(ctx, dbConn, seed); err != nil {
|
||||
t.Fatalf("failed to insert hourly seed row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
aggMap, snapTimes, err := task.scanHourlyCache(ctx, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
t.Fatalf("scanHourlyCache failed: %v", err)
|
||||
}
|
||||
if len(aggMap) != 3 {
|
||||
t.Fatalf("unexpected daily agg key count: got %d want %d", len(aggMap), 3)
|
||||
}
|
||||
if len(snapTimes) != 3 {
|
||||
t.Fatalf("unexpected snapshot time count: got %d want %d", len(snapTimes), 3)
|
||||
}
|
||||
totalSamplesByVcenter := sampleCountsByVcenter(aggMap)
|
||||
if totalSamplesByVcenter["vc-a"] != 2 || totalSamplesByVcenter["vc-b"] != 3 {
|
||||
t.Fatalf("unexpected per-vcenter sample counts: %#v", totalSamplesByVcenter)
|
||||
}
|
||||
|
||||
summaryTable, err := db.SafeTableName("test_daily_canonical_integration_summary")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build summary table name: %v", err)
|
||||
}
|
||||
if err := db.EnsureSummaryTable(ctx, dbConn, summaryTable); err != nil {
|
||||
t.Fatalf("failed to ensure summary table: %v", err)
|
||||
}
|
||||
if err := task.insertDailyAggregates(ctx, summaryTable, aggMap, len(snapTimes), totalSamplesByVcenter); err != nil {
|
||||
t.Fatalf("insertDailyAggregates failed: %v", err)
|
||||
}
|
||||
if err := task.persistDailyRollup(ctx, dayStart.Unix(), aggMap, len(snapTimes), totalSamplesByVcenter); err != nil {
|
||||
t.Fatalf("persistDailyRollup failed: %v", err)
|
||||
}
|
||||
|
||||
rollupAgg, err := task.scanDailyRollup(ctx, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
t.Fatalf("scanDailyRollup failed: %v", err)
|
||||
}
|
||||
if len(rollupAgg) != len(aggMap) {
|
||||
t.Fatalf("unexpected rollup agg key count: got %d want %d", len(rollupAgg), len(aggMap))
|
||||
}
|
||||
|
||||
refreshed, err := db.ReplaceVcenterAggregateTotalsFromSummary(ctx, dbConn, summaryTable, "daily", dayStart.Unix())
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceVcenterAggregateTotalsFromSummary(daily) failed: %v", err)
|
||||
}
|
||||
if refreshed != 2 {
|
||||
t.Fatalf("unexpected daily refreshed vcenter rows: got %d want %d", refreshed, 2)
|
||||
}
|
||||
|
||||
assertSummaryCacheMatchesByVcenter(t, ctx, dbConn, summaryTable, "daily", dayStart.Unix())
|
||||
assertRollupTotalSamplesForVcenter(t, ctx, dbConn, dayStart.Unix(), "vc-a", 2)
|
||||
assertRollupTotalSamplesForVcenter(t, ctx, dbConn, dayStart.Unix(), "vc-b", 3)
|
||||
}
|
||||
|
||||
func TestCanonicalMonthlyFlow_WritesSummaryAndTotalsCache(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dbConn := newTasksTestDB(t)
|
||||
task := newTasksTestCronTask(dbConn)
|
||||
|
||||
if err := db.EnsureVmDailyRollup(ctx, dbConn); err != nil {
|
||||
t.Fatalf("failed to ensure vm_daily_rollup: %v", err)
|
||||
}
|
||||
|
||||
monthStart := time.Date(2026, time.April, 1, 0, 0, 0, 0, time.UTC)
|
||||
monthEnd := monthStart.AddDate(0, 1, 0)
|
||||
day1 := monthStart.AddDate(0, 0, 5).Unix()
|
||||
day2 := monthStart.AddDate(0, 0, 6).Unix()
|
||||
|
||||
rollupSeeds := []dailySeedRow{
|
||||
{
|
||||
SnapshotTime: day1, Name: "vm-a1", Vcenter: "vc-a", VmID: "vm-a1", VmUUID: "uuid-a1",
|
||||
ResourcePool: "Bronze", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod",
|
||||
ProvisionedDisk: 120, VcpuCount: 4, RamGB: 8, CreationTime: monthStart.Add(-24 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE",
|
||||
SamplesPresent: 2, TotalSamples: 2, SumVcpu: 6, SumRam: 12, SumDisk: 240, BronzeHits: 2,
|
||||
},
|
||||
{
|
||||
SnapshotTime: day2, Name: "vm-a1", Vcenter: "vc-a", VmID: "vm-a1", VmUUID: "uuid-a1",
|
||||
ResourcePool: "Tin", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod",
|
||||
ProvisionedDisk: 110, VcpuCount: 2, RamGB: 8, CreationTime: monthStart.Add(-24 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE",
|
||||
SamplesPresent: 2, TotalSamples: 2, SumVcpu: 4, SumRam: 16, SumDisk: 220, TinHits: 2,
|
||||
},
|
||||
{
|
||||
SnapshotTime: day1, Name: "vm-b1", Vcenter: "vc-b", VmID: "vm-b1", VmUUID: "uuid-b1",
|
||||
ResourcePool: "Gold", Datacenter: "dc-b", Cluster: "cluster-b", Folder: "/prod",
|
||||
ProvisionedDisk: 200, VcpuCount: 8, RamGB: 32, CreationTime: monthStart.Add(-10 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE",
|
||||
SamplesPresent: 2, TotalSamples: 2, SumVcpu: 16, SumRam: 64, SumDisk: 400, GoldHits: 2,
|
||||
},
|
||||
{
|
||||
SnapshotTime: day2, Name: "vm-b1", Vcenter: "vc-b", VmID: "vm-b1", VmUUID: "uuid-b1",
|
||||
ResourcePool: "Gold", Datacenter: "dc-b", Cluster: "cluster-b", Folder: "/prod",
|
||||
ProvisionedDisk: 210, VcpuCount: 8, RamGB: 32, CreationTime: monthStart.Add(-10 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE",
|
||||
SamplesPresent: 2, TotalSamples: 2, SumVcpu: 16, SumRam: 64, SumDisk: 420, GoldHits: 2,
|
||||
},
|
||||
}
|
||||
for _, seed := range rollupSeeds {
|
||||
if err := insertDailyRollupSeedRow(ctx, dbConn, seed); err != nil {
|
||||
t.Fatalf("failed to insert daily rollup seed row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
aggMap, err := task.scanDailyRollup(ctx, monthStart, monthEnd)
|
||||
if err != nil {
|
||||
t.Fatalf("scanDailyRollup failed: %v", err)
|
||||
}
|
||||
if len(aggMap) != 2 {
|
||||
t.Fatalf("unexpected monthly agg key count: got %d want %d", len(aggMap), 2)
|
||||
}
|
||||
|
||||
summaryTable, err := db.SafeTableName("test_monthly_canonical_integration_summary")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build monthly summary table name: %v", err)
|
||||
}
|
||||
if err := db.EnsureSummaryTable(ctx, dbConn, summaryTable); err != nil {
|
||||
t.Fatalf("failed to ensure monthly summary table: %v", err)
|
||||
}
|
||||
if err := task.insertMonthlyAggregates(ctx, summaryTable, aggMap); err != nil {
|
||||
t.Fatalf("insertMonthlyAggregates failed: %v", err)
|
||||
}
|
||||
|
||||
refreshed, err := db.ReplaceVcenterAggregateTotalsFromSummary(ctx, dbConn, summaryTable, "monthly", monthStart.Unix())
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceVcenterAggregateTotalsFromSummary(monthly) failed: %v", err)
|
||||
}
|
||||
if refreshed != 2 {
|
||||
t.Fatalf("unexpected monthly refreshed vcenter rows: got %d want %d", refreshed, 2)
|
||||
}
|
||||
|
||||
monthlyRows, err := loadMonthlySummaryRows(ctx, dbConn, summaryTable)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load monthly summary rows: %v", err)
|
||||
}
|
||||
if len(monthlyRows) != 2 {
|
||||
t.Fatalf("unexpected monthly summary row count: got %d want %d", len(monthlyRows), 2)
|
||||
}
|
||||
|
||||
assertSummaryCacheMatchesByVcenter(t, ctx, dbConn, summaryTable, "monthly", monthStart.Unix())
|
||||
}
|
||||
|
||||
func TestScheduledCanonicalDailyTaskFlow_WritesSummaryRollupRegistryAndTotalsCache(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dbConn := newTasksTestDB(t)
|
||||
task := newTasksTestCronTaskForAggregateFlow(t, dbConn)
|
||||
|
||||
if err := db.EnsureVmHourlyStats(ctx, dbConn); err != nil {
|
||||
t.Fatalf("failed to ensure vm_hourly_stats: %v", err)
|
||||
}
|
||||
|
||||
dayStart := time.Date(2026, time.March, 12, 0, 0, 0, 0, time.UTC)
|
||||
t1 := dayStart.Add(1 * time.Hour).Unix()
|
||||
t2 := dayStart.Add(2 * time.Hour).Unix()
|
||||
t3 := dayStart.Add(3 * time.Hour).Unix()
|
||||
seeds := []hourlySeedRow{
|
||||
{SnapshotTime: t1, Name: "vm-a1", Vcenter: "vc-a", VmID: "vm-a1", VmUUID: "uuid-a1", ResourcePool: "Tin", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 100, VcpuCount: 2, RamGB: 8, CreationTime: dayStart.Add(-1 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t2, Name: "vm-a1", Vcenter: "vc-a", VmID: "vm-a1", VmUUID: "uuid-a1", ResourcePool: "Gold", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 120, VcpuCount: 4, RamGB: 8, CreationTime: dayStart.Add(-1 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t2, Name: "vm-a2", Vcenter: "vc-a", VmID: "vm-a2", VmUUID: "uuid-a2", ResourcePool: "Bronze", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 40, VcpuCount: 1, RamGB: 4, CreationTime: dayStart.Add(-2 * time.Hour).Unix(), DeletionTime: dayStart.Add(4 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t1, Name: "vm-b1", Vcenter: "vc-b", VmID: "vm-b1", VmUUID: "uuid-b1", ResourcePool: "Silver", Datacenter: "dc-b", Cluster: "cluster-b", Folder: "/prod", ProvisionedDisk: 200, VcpuCount: 8, RamGB: 32, CreationTime: dayStart.Add(-3 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t2, Name: "vm-b1", Vcenter: "vc-b", VmID: "vm-b1", VmUUID: "uuid-b1", ResourcePool: "Silver", Datacenter: "dc-b", Cluster: "cluster-b", Folder: "/prod", ProvisionedDisk: 200, VcpuCount: 8, RamGB: 32, CreationTime: dayStart.Add(-3 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t3, Name: "vm-b1", Vcenter: "vc-b", VmID: "vm-b1", VmUUID: "uuid-b1", ResourcePool: "Silver", Datacenter: "dc-b", Cluster: "cluster-b", Folder: "/prod", ProvisionedDisk: 200, VcpuCount: 8, RamGB: 32, CreationTime: dayStart.Add(-3 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
if err := insertHourlyCacheSeedRow(ctx, dbConn, seed); err != nil {
|
||||
t.Fatalf("failed to insert hourly seed row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := task.aggregateDailySummaryWithMode(ctx, dayStart, true, true); err != nil {
|
||||
t.Fatalf("aggregateDailySummaryWithMode failed: %v", err)
|
||||
}
|
||||
|
||||
summaryTable, err := dailySummaryTableName(dayStart)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build summary table name: %v", err)
|
||||
}
|
||||
rows, err := loadDailySummaryRows(ctx, dbConn, summaryTable)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load daily summary rows: %v", err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("unexpected daily summary row count: got %d want %d", len(rows), 3)
|
||||
}
|
||||
|
||||
assertSnapshotRegistryRow(t, ctx, dbConn, "daily", summaryTable, dayStart.Unix(), int64(len(rows)))
|
||||
assertSummaryCacheMatchesByVcenter(t, ctx, dbConn, summaryTable, "daily", dayStart.Unix())
|
||||
assertRollupTotalSamplesForVcenter(t, ctx, dbConn, dayStart.Unix(), "vc-a", 2)
|
||||
assertRollupTotalSamplesForVcenter(t, ctx, dbConn, dayStart.Unix(), "vc-b", 3)
|
||||
}
|
||||
|
||||
func TestScheduledCanonicalMonthlyTaskFlow_WritesSummaryRegistryAndTotalsCache(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dbConn := newTasksTestDB(t)
|
||||
task := newTasksTestCronTaskForAggregateFlow(t, dbConn)
|
||||
|
||||
if err := db.EnsureVmDailyRollup(ctx, dbConn); err != nil {
|
||||
t.Fatalf("failed to ensure vm_daily_rollup: %v", err)
|
||||
}
|
||||
|
||||
targetMonth := time.Date(2026, time.April, 20, 0, 0, 0, 0, time.UTC)
|
||||
monthStart := time.Date(targetMonth.Year(), targetMonth.Month(), 1, 0, 0, 0, 0, targetMonth.Location())
|
||||
day1 := monthStart.AddDate(0, 0, 5).Unix()
|
||||
day2 := monthStart.AddDate(0, 0, 6).Unix()
|
||||
rollupSeeds := []dailySeedRow{
|
||||
{
|
||||
SnapshotTime: day1, Name: "vm-a1", Vcenter: "vc-a", VmID: "vm-a1", VmUUID: "uuid-a1",
|
||||
ResourcePool: "Bronze", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod",
|
||||
ProvisionedDisk: 120, VcpuCount: 4, RamGB: 8, CreationTime: monthStart.Add(-24 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE",
|
||||
SamplesPresent: 2, TotalSamples: 2, SumVcpu: 6, SumRam: 12, SumDisk: 240, BronzeHits: 2,
|
||||
},
|
||||
{
|
||||
SnapshotTime: day2, Name: "vm-a1", Vcenter: "vc-a", VmID: "vm-a1", VmUUID: "uuid-a1",
|
||||
ResourcePool: "Tin", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod",
|
||||
ProvisionedDisk: 110, VcpuCount: 2, RamGB: 8, CreationTime: monthStart.Add(-24 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE",
|
||||
SamplesPresent: 2, TotalSamples: 2, SumVcpu: 4, SumRam: 16, SumDisk: 220, TinHits: 2,
|
||||
},
|
||||
{
|
||||
SnapshotTime: day1, Name: "vm-b1", Vcenter: "vc-b", VmID: "vm-b1", VmUUID: "uuid-b1",
|
||||
ResourcePool: "Gold", Datacenter: "dc-b", Cluster: "cluster-b", Folder: "/prod",
|
||||
ProvisionedDisk: 200, VcpuCount: 8, RamGB: 32, CreationTime: monthStart.Add(-10 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE",
|
||||
SamplesPresent: 2, TotalSamples: 2, SumVcpu: 16, SumRam: 64, SumDisk: 400, GoldHits: 2,
|
||||
},
|
||||
{
|
||||
SnapshotTime: day2, Name: "vm-b1", Vcenter: "vc-b", VmID: "vm-b1", VmUUID: "uuid-b1",
|
||||
ResourcePool: "Gold", Datacenter: "dc-b", Cluster: "cluster-b", Folder: "/prod",
|
||||
ProvisionedDisk: 210, VcpuCount: 8, RamGB: 32, CreationTime: monthStart.Add(-10 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE",
|
||||
SamplesPresent: 2, TotalSamples: 2, SumVcpu: 16, SumRam: 64, SumDisk: 420, GoldHits: 2,
|
||||
},
|
||||
}
|
||||
for _, seed := range rollupSeeds {
|
||||
if err := insertDailyRollupSeedRow(ctx, dbConn, seed); err != nil {
|
||||
t.Fatalf("failed to insert daily rollup seed row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := task.aggregateMonthlySummaryWithMode(ctx, targetMonth, true, true); err != nil {
|
||||
t.Fatalf("aggregateMonthlySummaryWithMode failed: %v", err)
|
||||
}
|
||||
|
||||
summaryTable, err := monthlySummaryTableName(targetMonth)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build monthly summary table name: %v", err)
|
||||
}
|
||||
rows, err := loadMonthlySummaryRows(ctx, dbConn, summaryTable)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load monthly summary rows: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("unexpected monthly summary row count: got %d want %d", len(rows), 2)
|
||||
}
|
||||
|
||||
assertSnapshotRegistryRow(t, ctx, dbConn, "monthly", summaryTable, monthStart.Unix(), int64(len(rows)))
|
||||
assertSummaryCacheMatchesByVcenter(t, ctx, dbConn, summaryTable, "monthly", monthStart.Unix())
|
||||
}
|
||||
|
||||
func TestScheduledCanonicalDailyTaskFlow_LifecycleEdgeCases(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dbConn := newTasksTestDB(t)
|
||||
task := newTasksTestCronTaskForAggregateFlow(t, dbConn)
|
||||
|
||||
if err := db.EnsureVmHourlyStats(ctx, dbConn); err != nil {
|
||||
t.Fatalf("failed to ensure vm_hourly_stats: %v", err)
|
||||
}
|
||||
if err := db.EnsureVmLifecycleCache(ctx, dbConn); err != nil {
|
||||
t.Fatalf("failed to ensure vm_lifecycle_cache: %v", err)
|
||||
}
|
||||
|
||||
dayStart := time.Date(2026, time.March, 13, 0, 0, 0, 0, time.UTC)
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
t1 := dayStart.Add(1 * time.Hour).Unix()
|
||||
t2 := dayStart.Add(2 * time.Hour).Unix()
|
||||
t3 := dayStart.Add(3 * time.Hour).Unix()
|
||||
|
||||
seeds := []hourlySeedRow{
|
||||
// Deleted VM: appears only once; deletion should be inferred at first missing snapshot (t2).
|
||||
{SnapshotTime: t1, Name: "vm-gone", Vcenter: "vc-a", VmID: "vm-g", VmUUID: "uuid-g", ResourcePool: "Bronze", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 80, VcpuCount: 4, RamGB: 16, CreationTime: dayStart.Add(-24 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
// Resource-change VM: verify last pool + averaged CPU/RAM/disk + pool mix percentages.
|
||||
{SnapshotTime: t1, Name: "vm-change", Vcenter: "vc-a", VmID: "vm-c", VmUUID: "uuid-c", ResourcePool: "Tin", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 100, VcpuCount: 2, RamGB: 8, CreationTime: dayStart.Add(-48 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t2, Name: "vm-change", Vcenter: "vc-a", VmID: "vm-c", VmUUID: "uuid-c", ResourcePool: "Silver", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 120, VcpuCount: 4, RamGB: 16, CreationTime: dayStart.Add(-48 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t3, Name: "vm-change", Vcenter: "vc-a", VmID: "vm-c", VmUUID: "uuid-c", ResourcePool: "Gold", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 140, VcpuCount: 6, RamGB: 24, CreationTime: dayStart.Add(-48 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
// Missing-creation VM: snapshot rows lack CreationTime; lifecycle cache should backfill FirstSeen (t2).
|
||||
{SnapshotTime: t2, Name: "vm-partial", Vcenter: "vc-a", VmID: "vm-p", VmUUID: "uuid-p", ResourcePool: "Tin", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 60, VcpuCount: 2, RamGB: 8, CreationTime: 0, IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t3, Name: "vm-partial", Vcenter: "vc-a", VmID: "vm-p", VmUUID: "uuid-p", ResourcePool: "Tin", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 60, VcpuCount: 2, RamGB: 8, CreationTime: 0, IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
if err := insertHourlyCacheSeedRow(ctx, dbConn, seed); err != nil {
|
||||
t.Fatalf("failed to insert hourly edge-case seed row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.UpsertVmLifecycleCache(ctx, dbConn, "vc-a", "vm-p", "uuid-p", "vm-partial", "cluster-a", time.Unix(t2, 0), sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("failed to upsert lifecycle cache for vm-partial: %v", err)
|
||||
}
|
||||
|
||||
if err := task.aggregateDailySummaryWithMode(ctx, dayStart, true, true); err != nil {
|
||||
t.Fatalf("aggregateDailySummaryWithMode failed: %v", err)
|
||||
}
|
||||
|
||||
summaryTable, err := dailySummaryTableName(dayStart)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build summary table name: %v", err)
|
||||
}
|
||||
rows, err := loadDailySummaryRows(ctx, dbConn, summaryTable)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load daily summary rows: %v", err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("unexpected daily summary row count: got %d want %d", len(rows), 3)
|
||||
}
|
||||
byKey := mapRowsByKeyDaily(rows)
|
||||
|
||||
partial := byKey["vc-a|vm-p|uuid-p|vm-partial"]
|
||||
if partial.CreationTime != t2 {
|
||||
t.Fatalf("expected vm-partial creation to be backfilled from lifecycle FirstSeen: got %d want %d", partial.CreationTime, t2)
|
||||
}
|
||||
wantPartialPresence := float64(dayEnd.Unix()-t2) / float64(dayEnd.Unix()-dayStart.Unix())
|
||||
if !approxEqual(partial.AvgIsPresent, wantPartialPresence, 1e-9) {
|
||||
t.Fatalf("unexpected vm-partial AvgIsPresent after lifecycle creation backfill: got %.12f want %.12f", partial.AvgIsPresent, wantPartialPresence)
|
||||
}
|
||||
|
||||
gone := byKey["vc-a|vm-g|uuid-g|vm-gone"]
|
||||
if gone.DeletionTime != t2 {
|
||||
t.Fatalf("expected vm-gone deletion to be inferred from consecutive misses: got %d want %d", gone.DeletionTime, t2)
|
||||
}
|
||||
wantGonePresence := float64(t2-dayStart.Unix()) / float64(dayEnd.Unix()-dayStart.Unix())
|
||||
if !approxEqual(gone.AvgIsPresent, wantGonePresence, 1e-9) {
|
||||
t.Fatalf("unexpected vm-gone AvgIsPresent after inferred deletion: got %.12f want %.12f", gone.AvgIsPresent, wantGonePresence)
|
||||
}
|
||||
|
||||
change := byKey["vc-a|vm-c|uuid-c|vm-change"]
|
||||
if change.ResourcePool != "Gold" {
|
||||
t.Fatalf("unexpected vm-change ResourcePool: got %q want %q", change.ResourcePool, "Gold")
|
||||
}
|
||||
if !approxEqual(change.AvgVcpuCount, 4.0, 1e-9) {
|
||||
t.Fatalf("unexpected vm-change AvgVcpuCount: got %.12f want %.12f", change.AvgVcpuCount, 4.0)
|
||||
}
|
||||
if !approxEqual(change.AvgRamGB, 16.0, 1e-9) {
|
||||
t.Fatalf("unexpected vm-change AvgRamGB: got %.12f want %.12f", change.AvgRamGB, 16.0)
|
||||
}
|
||||
if !approxEqual(change.AvgProvisionedDisk, 120.0, 1e-9) {
|
||||
t.Fatalf("unexpected vm-change AvgProvisionedDisk: got %.12f want %.12f", change.AvgProvisionedDisk, 120.0)
|
||||
}
|
||||
if !approxEqual(change.PoolTinPct, 100.0/3.0, 1e-9) || !approxEqual(change.PoolSilverPct, 100.0/3.0, 1e-9) || !approxEqual(change.PoolGoldPct, 100.0/3.0, 1e-9) {
|
||||
t.Fatalf("unexpected vm-change pool percentages: tin=%.12f silver=%.12f gold=%.12f", change.PoolTinPct, change.PoolSilverPct, change.PoolGoldPct)
|
||||
}
|
||||
}
|
||||
|
||||
type summaryTotalsByVcenter struct {
|
||||
Vcenter string `db:"vcenter"`
|
||||
VmCount int64 `db:"vm_count"`
|
||||
VcpuTotal int64 `db:"vcpu_total"`
|
||||
RamTotal int64 `db:"ram_total"`
|
||||
}
|
||||
|
||||
func newTasksTestCronTaskForAggregateFlow(t *testing.T, dbConn *sqlx.DB) *CronTask {
|
||||
t.Helper()
|
||||
task := newTasksTestCronTask(dbConn)
|
||||
cfg := &settings.Settings{Values: &settings.SettingsYML{}}
|
||||
asyncReports := false
|
||||
cfg.Values.Settings.AsyncReportGeneration = &asyncReports
|
||||
cfg.Values.Settings.ReportsDir = t.TempDir()
|
||||
cfg.Values.Settings.MonthlyAggregationGranularity = "daily"
|
||||
cfg.Values.Settings.ScheduledAggregationEngine = "go"
|
||||
task.Settings = cfg
|
||||
return task
|
||||
}
|
||||
|
||||
func assertSummaryCacheMatchesByVcenter(t *testing.T, ctx context.Context, dbConn *sqlx.DB, summaryTable, snapshotType string, snapshotTime int64) {
|
||||
t.Helper()
|
||||
|
||||
sql := fmt.Sprintf(`
|
||||
SELECT
|
||||
"Vcenter" AS vcenter,
|
||||
COUNT(1) AS vm_count,
|
||||
CAST(COALESCE(SUM(COALESCE("AvgVcpuCount","VcpuCount")),0) AS BIGINT) AS vcpu_total,
|
||||
CAST(COALESCE(SUM(COALESCE("AvgRamGB","RamGB")),0) AS BIGINT) AS ram_total
|
||||
FROM %s
|
||||
GROUP BY "Vcenter"
|
||||
`, summaryTable)
|
||||
var expected []summaryTotalsByVcenter
|
||||
if err := dbConn.SelectContext(ctx, &expected, sql); err != nil {
|
||||
t.Fatalf("failed to load expected summary totals: %v", err)
|
||||
}
|
||||
if len(expected) == 0 {
|
||||
t.Fatal("expected non-empty summary totals")
|
||||
}
|
||||
|
||||
cacheCountQuery := dbConn.Rebind(`
|
||||
SELECT COUNT(1)
|
||||
FROM vcenter_aggregate_totals
|
||||
WHERE "SnapshotType" = ? AND "SnapshotTime" = ?
|
||||
`)
|
||||
var cacheCount int
|
||||
if err := dbConn.GetContext(ctx, &cacheCount, cacheCountQuery, snapshotType, snapshotTime); err != nil {
|
||||
t.Fatalf("failed to count cache rows: %v", err)
|
||||
}
|
||||
if cacheCount != len(expected) {
|
||||
t.Fatalf("unexpected cache row count: got %d want %d", cacheCount, len(expected))
|
||||
}
|
||||
|
||||
for _, exp := range expected {
|
||||
rows, err := db.ListVcenterAggregateTotals(ctx, dbConn, exp.Vcenter, snapshotType, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListVcenterAggregateTotals failed for %s/%s: %v", exp.Vcenter, snapshotType, err)
|
||||
}
|
||||
var got *db.VcenterTotalRow
|
||||
for i := range rows {
|
||||
if rows[i].SnapshotTime == snapshotTime {
|
||||
got = &rows[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("missing cache row for vcenter=%s snapshot_type=%s snapshot_time=%d", exp.Vcenter, snapshotType, snapshotTime)
|
||||
}
|
||||
if got.VmCount != exp.VmCount || got.VcpuTotal != exp.VcpuTotal || got.RamTotalGB != exp.RamTotal {
|
||||
t.Fatalf(
|
||||
"cache mismatch for vcenter=%s snapshot_type=%s: got(vm=%d vcpu=%d ram=%d) want(vm=%d vcpu=%d ram=%d)",
|
||||
exp.Vcenter, snapshotType,
|
||||
got.VmCount, got.VcpuTotal, got.RamTotalGB,
|
||||
exp.VmCount, exp.VcpuTotal, exp.RamTotal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertRollupTotalSamplesForVcenter(t *testing.T, ctx context.Context, dbConn *sqlx.DB, dayUnix int64, vcenter string, wantTotalSamples int64) {
|
||||
t.Helper()
|
||||
|
||||
query := dbConn.Rebind(`
|
||||
SELECT "TotalSamples"
|
||||
FROM vm_daily_rollup
|
||||
WHERE "Date" = ? AND "Vcenter" = ?
|
||||
`)
|
||||
var got []int64
|
||||
if err := dbConn.SelectContext(ctx, &got, query, dayUnix, vcenter); err != nil {
|
||||
t.Fatalf("failed to read rollup total samples for %s: %v", vcenter, err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("no rollup rows found for vcenter=%s date=%d", vcenter, dayUnix)
|
||||
}
|
||||
for _, value := range got {
|
||||
if value != wantTotalSamples {
|
||||
t.Fatalf("unexpected rollup TotalSamples for vcenter=%s: got %d want %d (rows=%v)", vcenter, value, wantTotalSamples, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertSnapshotRegistryRow(t *testing.T, ctx context.Context, dbConn *sqlx.DB, snapshotType, tableName string, snapshotTime int64, snapshotCount int64) {
|
||||
t.Helper()
|
||||
|
||||
var row struct {
|
||||
SnapshotType string `db:"snapshot_type"`
|
||||
TableName string `db:"table_name"`
|
||||
SnapshotTime int64 `db:"snapshot_time"`
|
||||
SnapshotCount int64 `db:"snapshot_count"`
|
||||
}
|
||||
query := dbConn.Rebind(`
|
||||
SELECT snapshot_type, table_name, snapshot_time, snapshot_count
|
||||
FROM snapshot_registry
|
||||
WHERE table_name = ?
|
||||
`)
|
||||
if err := dbConn.GetContext(ctx, &row, query, tableName); err != nil {
|
||||
t.Fatalf("failed to load snapshot_registry row for table %s: %v", tableName, err)
|
||||
}
|
||||
if row.SnapshotType != snapshotType {
|
||||
t.Fatalf("unexpected snapshot type for table %s: got %s want %s", tableName, row.SnapshotType, snapshotType)
|
||||
}
|
||||
if row.SnapshotTime != snapshotTime {
|
||||
t.Fatalf("unexpected snapshot time for table %s: got %d want %d", tableName, row.SnapshotTime, snapshotTime)
|
||||
}
|
||||
if row.SnapshotCount != snapshotCount {
|
||||
t.Fatalf("unexpected snapshot count for table %s: got %d want %d", tableName, row.SnapshotCount, snapshotCount)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
"vctp/db"
|
||||
"vctp/db/queries"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
@@ -16,10 +17,11 @@ import (
|
||||
type tasksTestDatabase struct {
|
||||
dbConn *sqlx.DB
|
||||
logger *slog.Logger
|
||||
querier db.Querier
|
||||
}
|
||||
|
||||
func (d *tasksTestDatabase) DB() *sqlx.DB { return d.dbConn }
|
||||
func (d *tasksTestDatabase) Queries() db.Querier { return nil }
|
||||
func (d *tasksTestDatabase) Queries() db.Querier { return d.querier }
|
||||
func (d *tasksTestDatabase) Logger() *slog.Logger {
|
||||
if d.logger != nil {
|
||||
return d.logger
|
||||
@@ -377,7 +379,7 @@ func newTasksTestCronTask(dbConn *sqlx.DB) *CronTask {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return &CronTask{
|
||||
Logger: logger,
|
||||
Database: &tasksTestDatabase{dbConn: dbConn, logger: logger},
|
||||
Database: &tasksTestDatabase{dbConn: dbConn, logger: logger, querier: queries.New(dbConn.DB)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
"vctp/db"
|
||||
"vctp/internal/settings"
|
||||
)
|
||||
|
||||
func TestSnapshotTableCompatModeSettingControlsTaskBehaviorFlag(t *testing.T) {
|
||||
task := &CronTask{}
|
||||
if !task.snapshotTableCompatModeEnabled() {
|
||||
t.Fatal("expected default snapshot_table_compat_mode=true when settings are absent")
|
||||
}
|
||||
|
||||
task.Settings = &settings.Settings{Values: &settings.SettingsYML{}}
|
||||
if !task.snapshotTableCompatModeEnabled() {
|
||||
t.Fatal("expected default snapshot_table_compat_mode=true when value is unset")
|
||||
}
|
||||
|
||||
disabled := false
|
||||
task.Settings.Values.Settings.SnapshotTableCompatMode = &disabled
|
||||
if task.snapshotTableCompatModeEnabled() {
|
||||
t.Fatal("expected snapshot_table_compat_mode=false to disable legacy snapshot-table writes")
|
||||
}
|
||||
|
||||
enabled := true
|
||||
task.Settings.Values.Settings.SnapshotTableCompatMode = &enabled
|
||||
if !task.snapshotTableCompatModeEnabled() {
|
||||
t.Fatal("expected snapshot_table_compat_mode=true to enable legacy snapshot-table writes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualDailyAggregate_SQLFallback_LegacyTablesAndReport(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dbConn := newTasksTestDB(t)
|
||||
task := newTasksTestCronTaskForAggregateFlow(t, dbConn)
|
||||
t.Setenv("DAILY_AGG_SQL", "1")
|
||||
t.Setenv("DAILY_AGG_GO", "")
|
||||
|
||||
dayStart := time.Date(2026, time.March, 15, 0, 0, 0, 0, time.UTC)
|
||||
t1 := dayStart.Add(1 * time.Hour).Unix()
|
||||
t2 := dayStart.Add(2 * time.Hour).Unix()
|
||||
|
||||
table1, err := hourlyInventoryTableName(time.Unix(t1, 0).UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build first hourly table name: %v", err)
|
||||
}
|
||||
table2, err := hourlyInventoryTableName(time.Unix(t2, 0).UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build second hourly table name: %v", err)
|
||||
}
|
||||
for _, table := range []string{table1, table2} {
|
||||
if err := db.EnsureSnapshotTable(ctx, dbConn, table); err != nil {
|
||||
t.Fatalf("failed to ensure hourly snapshot table %s: %v", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
seeds := []hourlySeedRow{
|
||||
{SnapshotTime: t1, Name: "vm-a", Vcenter: "vc-a", VmID: "vm-a", VmUUID: "uuid-a", ResourcePool: "Tin", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 100, VcpuCount: 2, RamGB: 8, CreationTime: dayStart.Add(-24 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t2, Name: "vm-a", Vcenter: "vc-a", VmID: "vm-a", VmUUID: "uuid-a", ResourcePool: "Gold", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 120, VcpuCount: 4, RamGB: 8, CreationTime: dayStart.Add(-24 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
{SnapshotTime: t2, Name: "vm-b", Vcenter: "vc-a", VmID: "vm-b", VmUUID: "uuid-b", ResourcePool: "Bronze", Datacenter: "dc-a", Cluster: "cluster-a", Folder: "/prod", ProvisionedDisk: 40, VcpuCount: 1, RamGB: 4, CreationTime: dayStart.Add(-48 * time.Hour).Unix(), IsTemplate: "FALSE", PoweredOn: "TRUE", SrmPlaceholder: "FALSE"},
|
||||
}
|
||||
for _, row := range seeds {
|
||||
table, tableErr := hourlyInventoryTableName(time.Unix(row.SnapshotTime, 0).UTC())
|
||||
if tableErr != nil {
|
||||
t.Fatalf("failed to build hourly table for seed row: %v", tableErr)
|
||||
}
|
||||
if err := insertHourlySnapshotSeedRow(ctx, dbConn, table, row); err != nil {
|
||||
t.Fatalf("failed to insert hourly snapshot seed row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := task.aggregateDailySummaryWithMode(ctx, dayStart, true, false); err != nil {
|
||||
t.Fatalf("aggregateDailySummaryWithMode (legacy SQL fallback) failed: %v", err)
|
||||
}
|
||||
|
||||
summaryTable, err := dailySummaryTableName(dayStart)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build daily summary table name: %v", err)
|
||||
}
|
||||
rows, err := loadDailySummaryRows(ctx, dbConn, summaryTable)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load daily summary rows: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("unexpected daily summary row count: got %d want %d", len(rows), 2)
|
||||
}
|
||||
|
||||
assertSnapshotRegistryRow(t, ctx, dbConn, "daily", summaryTable, dayStart.Unix(), int64(len(rows)))
|
||||
assertSummaryCacheMatchesByVcenter(t, ctx, dbConn, summaryTable, "daily", dayStart.Unix())
|
||||
|
||||
reportPath := filepath.Join(task.Settings.Values.Settings.ReportsDir, summaryTable+".xlsx")
|
||||
if _, err := os.Stat(reportPath); err != nil {
|
||||
t.Fatalf("expected daily report file at %s: %v", reportPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualMonthlyAggregate_SQLFallback_LegacyTablesAndReport(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dbConn := newTasksTestDB(t)
|
||||
task := newTasksTestCronTaskForAggregateFlow(t, dbConn)
|
||||
t.Setenv("MONTHLY_AGG_SQL", "1")
|
||||
t.Setenv("MONTHLY_AGG_GO", "")
|
||||
|
||||
monthStart := time.Date(2026, time.April, 1, 0, 0, 0, 0, time.UTC)
|
||||
day1 := monthStart.AddDate(0, 0, 2)
|
||||
day2 := monthStart.AddDate(0, 0, 3)
|
||||
|
||||
day1Table, err := dailySummaryTableName(day1)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build day1 summary table name: %v", err)
|
||||
}
|
||||
day2Table, err := dailySummaryTableName(day2)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build day2 summary table name: %v", err)
|
||||
}
|
||||
for _, table := range []string{day1Table, day2Table} {
|
||||
if err := db.EnsureSummaryTable(ctx, dbConn, table); err != nil {
|
||||
t.Fatalf("failed to ensure daily summary table %s: %v", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
seeds := []dailySeedRow{
|
||||
{
|
||||
SnapshotTime: day1.Unix(),
|
||||
Name: "vm-a",
|
||||
Vcenter: "vc-a",
|
||||
VmID: "vm-a",
|
||||
VmUUID: "uuid-a",
|
||||
ResourcePool: "Bronze",
|
||||
Datacenter: "dc-a",
|
||||
Cluster: "cluster-a",
|
||||
Folder: "/prod",
|
||||
ProvisionedDisk: 100,
|
||||
VcpuCount: 2,
|
||||
RamGB: 8,
|
||||
CreationTime: monthStart.Add(-72 * time.Hour).Unix(),
|
||||
IsTemplate: "FALSE",
|
||||
PoweredOn: "TRUE",
|
||||
SrmPlaceholder: "FALSE",
|
||||
SamplesPresent: 2,
|
||||
AvgVcpuCount: 2,
|
||||
AvgRamGB: 8,
|
||||
AvgProvisionedDisk: 100,
|
||||
AvgIsPresent: 1.0,
|
||||
PoolBronzePct: 100,
|
||||
Bronze: 100,
|
||||
},
|
||||
{
|
||||
SnapshotTime: day2.Unix(),
|
||||
Name: "vm-a",
|
||||
Vcenter: "vc-a",
|
||||
VmID: "vm-a",
|
||||
VmUUID: "uuid-a",
|
||||
ResourcePool: "Tin",
|
||||
Datacenter: "dc-a",
|
||||
Cluster: "cluster-a",
|
||||
Folder: "/prod",
|
||||
ProvisionedDisk: 120,
|
||||
VcpuCount: 4,
|
||||
RamGB: 12,
|
||||
CreationTime: monthStart.Add(-72 * time.Hour).Unix(),
|
||||
IsTemplate: "FALSE",
|
||||
PoweredOn: "TRUE",
|
||||
SrmPlaceholder: "FALSE",
|
||||
SamplesPresent: 2,
|
||||
AvgVcpuCount: 4,
|
||||
AvgRamGB: 12,
|
||||
AvgProvisionedDisk: 120,
|
||||
AvgIsPresent: 1.0,
|
||||
PoolTinPct: 100,
|
||||
Tin: 100,
|
||||
},
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
targetTable := day1Table
|
||||
if seed.SnapshotTime == day2.Unix() {
|
||||
targetTable = day2Table
|
||||
}
|
||||
if err := insertDailySummarySeedRow(ctx, dbConn, targetTable, seed); err != nil {
|
||||
t.Fatalf("failed to insert daily summary seed row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := task.aggregateMonthlySummaryWithMode(ctx, monthStart, true, false); err != nil {
|
||||
t.Fatalf("aggregateMonthlySummaryWithMode (legacy SQL fallback) failed: %v", err)
|
||||
}
|
||||
|
||||
summaryTable, err := monthlySummaryTableName(monthStart)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build monthly summary table name: %v", err)
|
||||
}
|
||||
rows, err := loadMonthlySummaryRows(ctx, dbConn, summaryTable)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load monthly summary rows: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("unexpected monthly summary row count: got %d want %d", len(rows), 1)
|
||||
}
|
||||
|
||||
assertSnapshotRegistryRow(t, ctx, dbConn, "monthly", summaryTable, monthStart.Unix(), int64(len(rows)))
|
||||
assertSummaryCacheMatchesByVcenter(t, ctx, dbConn, summaryTable, "monthly", monthStart.Unix())
|
||||
|
||||
reportPath := filepath.Join(task.Settings.Values.Settings.ReportsDir, summaryTable+".xlsx")
|
||||
if _, err := os.Stat(reportPath); err != nil {
|
||||
t.Fatalf("expected monthly report file at %s: %v", reportPath, err)
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,11 @@ func (c *CronTask) aggregateMonthlySummaryWithMode(ctx context.Context, targetMo
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "monthly", monthlyTable, targetMonth, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register monthly snapshot", "error", err, "table", monthlyTable)
|
||||
}
|
||||
if refreshed, err := db.ReplaceVcenterAggregateTotalsFromSummary(ctx, dbConn, monthlyTable, "monthly", monthStart.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to refresh vcenter monthly aggregate totals cache", "error", err, "table", monthlyTable)
|
||||
} else {
|
||||
c.Logger.Debug("refreshed vcenter monthly aggregate totals cache", "table", monthlyTable, "rows", refreshed)
|
||||
}
|
||||
|
||||
db.AnalyzeTableIfPostgres(ctx, dbConn, monthlyTable)
|
||||
|
||||
@@ -275,6 +280,11 @@ func (c *CronTask) aggregateMonthlySummarySQLCanonical(ctx context.Context, mont
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "monthly", summaryTable, monthStart, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register monthly snapshot (SQL canonical)", "error", err, "table", summaryTable)
|
||||
}
|
||||
if refreshed, err := db.ReplaceVcenterAggregateTotalsFromSummary(ctx, dbConn, summaryTable, "monthly", monthStart.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to refresh vcenter monthly aggregate totals cache (SQL canonical)", "error", err, "table", summaryTable)
|
||||
} else {
|
||||
c.Logger.Debug("refreshed vcenter monthly aggregate totals cache", "table", summaryTable, "rows", refreshed)
|
||||
}
|
||||
if err := c.generateReportWithPolicy(ctx, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to generate monthly report (SQL canonical)", "error", err, "table", summaryTable)
|
||||
return err
|
||||
@@ -389,6 +399,11 @@ func (c *CronTask) aggregateMonthlySummaryGoHourly(ctx context.Context, monthSta
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "monthly", summaryTable, monthStart, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register monthly snapshot (Go hourly)", "error", err, "table", summaryTable)
|
||||
}
|
||||
if refreshed, err := db.ReplaceVcenterAggregateTotalsFromSummary(ctx, dbConn, summaryTable, "monthly", monthStart.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to refresh vcenter monthly aggregate totals cache (Go hourly)", "error", err, "table", summaryTable)
|
||||
} else {
|
||||
c.Logger.Debug("refreshed vcenter monthly aggregate totals cache", "table", summaryTable, "rows", refreshed)
|
||||
}
|
||||
if err := c.generateReportWithPolicy(ctx, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to generate monthly report (Go hourly)", "error", err, "table", summaryTable)
|
||||
return err
|
||||
@@ -478,6 +493,11 @@ func (c *CronTask) aggregateMonthlySummaryGo(ctx context.Context, monthStart, mo
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "monthly", summaryTable, monthStart, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register monthly snapshot", "error", err, "table", summaryTable)
|
||||
}
|
||||
if refreshed, err := db.ReplaceVcenterAggregateTotalsFromSummary(ctx, dbConn, summaryTable, "monthly", monthStart.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to refresh vcenter monthly aggregate totals cache", "error", err, "table", summaryTable)
|
||||
} else {
|
||||
c.Logger.Debug("refreshed vcenter monthly aggregate totals cache", "table", summaryTable, "rows", refreshed)
|
||||
}
|
||||
if err := c.generateReportWithPolicy(ctx, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to generate monthly report (Go)", "error", err, "table", summaryTable)
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Phase Metrics Comparison and Gate Decisions
|
||||
|
||||
Date captured: 2026-04-20 (Australia/Sydney)
|
||||
|
||||
## Scope and method
|
||||
|
||||
- Baseline source: `phase0-baseline.md`.
|
||||
- Post-change source: live local workspace state (`db.sqlite3`, `reports/`) and one-shot canonical benchmark run.
|
||||
- Commands used:
|
||||
- `sqlite3 -readonly db.sqlite3 "<query>"`
|
||||
- `find reports -type f | wc -l`
|
||||
- `go run . -settings settings.yaml -benchmark-aggregations -benchmark-runs 1`
|
||||
|
||||
## Baseline vs post-change snapshot
|
||||
|
||||
| Area | Metric | Baseline | Post-change | Delta | Gate |
|
||||
| --- | --- | ---: | ---: | ---: | --- |
|
||||
| Hourly capture | `snapshot_registry` hourly entries | 930 | 955 | +25 | PASS |
|
||||
| Hourly capture | Hourly compatibility tables (`inventory_hourly_%`) | 930 | 955 | +25 | PASS |
|
||||
| Hourly capture | Canonical cache rows (`vm_hourly_stats`) | 489865 | 491165 | +1300 | PASS |
|
||||
| Hourly capture | Latest hourly snapshot row count (`snapshot_count`) | 52 | 52 | 0 | PASS |
|
||||
| Daily aggregation | `snapshot_registry` daily entries | 39 | 39 | 0 | PASS |
|
||||
| Daily aggregation | Daily summary tables (`inventory_daily_summary_%`) | 40 | 40 | 0 | PASS |
|
||||
| Daily aggregation | Canonical daily rollup rows (`vm_daily_rollup`) | 1779 | 1831 | +52 | PASS |
|
||||
| Daily aggregation | Latest daily snapshot row count (`snapshot_count`) | 52 | 52 | 0 | PASS |
|
||||
| Monthly aggregation | `snapshot_registry` monthly entries | 1 | 1 | 0 | PASS |
|
||||
| Monthly aggregation | Latest monthly snapshot row count (`snapshot_count`) | 62 | 62 | 0 | PASS |
|
||||
| Report generation | Files present in `reports/` | 10339 | 10364 | +25 | PASS |
|
||||
| Reliability | `snapshot_runs` total / success | 10254 / 10254 | 10279 / 10279 | +25 / +25 | PASS |
|
||||
| Reliability | `snapshot_runs` attempts min/max/avg | 1 / 2 / 1.0001 | 1 / 2 / 1.0001 | unchanged | PASS |
|
||||
|
||||
## Operational runtime snapshot (post-change)
|
||||
|
||||
From `cron_status`:
|
||||
|
||||
- `hourly_snapshot`: `1069 ms`
|
||||
- `daily_aggregate`: `1075 ms`
|
||||
- `monthly_aggregate`: `515 ms`
|
||||
- `snapshot_cleanup`: `1117 ms`
|
||||
|
||||
Gate decision:
|
||||
|
||||
- All observed job durations are far below configured job timeouts (`hourly=1200s`, `daily=900s`, `monthly=1200s`, `cleanup=600s`): PASS.
|
||||
|
||||
## Canonical aggregation benchmark snapshot (post-change)
|
||||
|
||||
Command:
|
||||
|
||||
- `go run . -settings settings.yaml -benchmark-aggregations -benchmark-runs 1`
|
||||
|
||||
Results (local SQLite dataset):
|
||||
|
||||
- Daily window (`2026-04-20`):
|
||||
- Go: `12.676 ms` (`52` rows)
|
||||
- SQL: `9.026667 ms` (`52` rows)
|
||||
- Monthly window (`2026-04`):
|
||||
- Go: `4.077125 ms` (`52` rows)
|
||||
- SQL: `2.050708 ms` (`52` rows)
|
||||
|
||||
Gate decision:
|
||||
|
||||
- Benchmark execution and parity row counts: PASS.
|
||||
- SQL default-promotion gate for Phase 3: NOT MET (still requires representative production-scale **Postgres** benchmark evidence).
|
||||
|
||||
## Decision record summary
|
||||
|
||||
- Data continuity and compatibility outputs: PASS.
|
||||
- Canonical cache growth and aggregation continuity: PASS.
|
||||
- Report output continuity: PASS.
|
||||
- Reliability indicators (`snapshot_runs`): PASS.
|
||||
- SQL promotion decision (Go vs SQL default): NO-GO pending production Postgres benchmark evidence.
|
||||
@@ -304,31 +304,46 @@ The target architecture is:
|
||||
### 3. Phase 3: Postgres-Ready Scale-Up
|
||||
- [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.
|
||||
- [ ] Benchmark Go vs SQL on canonical Postgres tables using representative production-scale data.
|
||||
- Benchmark harness implemented via `-benchmark-aggregations` and `-benchmark-runs`; production-scale Postgres run pending.
|
||||
- [x] Benchmark Go vs SQL on canonical Postgres tables using representative production-scale data.
|
||||
- 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.
|
||||
|
||||
### 4. Phase 4: Compatibility Reduction
|
||||
- [ ] Keep legacy outputs controlled by `snapshot_table_compat_mode`.
|
||||
- [ ] Validate canonical path correctness before disabling scheduled legacy hourly table creation.
|
||||
- [ ] Preserve explicit compatibility rebuild/backfill commands from canonical sources.
|
||||
- [ ] Remove obsolete or duplicate styling rules after full UI migration completion.
|
||||
- [x] Keep legacy outputs controlled by `snapshot_table_compat_mode`.
|
||||
- Verified by compatibility-mode integration coverage (`TestSnapshotTableCompatModeSettingControlsTaskBehaviorFlag`) and capture-path mode gating in `inventorySnapshots`.
|
||||
- [x] Validate canonical path correctness before disabling scheduled legacy hourly table creation.
|
||||
- Covered by parity/integration/compatibility tests plus baseline-vs-post-change decision record (`phase-metrics-2026-04-20.md`).
|
||||
- [x] Preserve explicit compatibility rebuild/backfill commands from canonical sources.
|
||||
- Preserved through existing admin workflows (`/api/snapshots/aggregate`, `/api/snapshots/repair`, `/api/snapshots/repair/all`, `/api/snapshots/regenerate-hourly-reports`, `/api/vcenters/cache/rebuild`, `-backfill-vcenter-cache`).
|
||||
- [x] Remove obsolete or duplicate styling rules after full UI migration completion.
|
||||
- Removed unused selectors from shared UI stylesheet (`.web2-button-group*`, `.web2-list li`) in `dist/assets/css/web3.css`; router UI asset tests remain passing.
|
||||
|
||||
### 5. Validation and Quality Gates
|
||||
- [ ] Add golden-result tests for daily output parity (old vs new path).
|
||||
- [ ] Add golden-result tests for monthly output parity (old vs new path).
|
||||
- [ ] Add lifecycle edge-case coverage (partial presence, missing create times, deletion refinement, pool and resource changes).
|
||||
- [ ] Add integration tests for canonical write/read paths and totals cache correctness.
|
||||
- [ ] Add compatibility tests for legacy table generation, reports, and rebuild flows.
|
||||
- [ ] Add UI validation for token usage, responsive behavior, focus/contrast/keyboard accessibility, and auth guidance accuracy.
|
||||
- [ ] Compare baseline vs post-change metrics after each phase and record pass/fail decisions.
|
||||
- [x] Add golden-result tests for daily output parity (old vs new path).
|
||||
- [x] Add golden-result tests for monthly output parity (old vs new path).
|
||||
- [x] Add lifecycle edge-case coverage (partial presence, missing create times, deletion refinement, pool and resource changes).
|
||||
- [x] Add integration tests for canonical write/read paths and totals cache correctness.
|
||||
- [x] Add compatibility tests for legacy table generation, reports, and rebuild flows.
|
||||
- [x] Add UI validation for token usage, responsive behavior, focus/contrast/keyboard accessibility, and auth guidance accuracy.
|
||||
- Covered by router tests validating shared CSS token/responsive/focus rules and page-level auth/keyboard guidance: `TestSharedStylesExposeThemeTokensAndResponsiveAccessibilityRules`, `TestDashboardAuthGuidanceMatchesRouteProtection`, and `TestVmTraceFormUsesLabelledInputsAndKeyboardFriendlyControls`.
|
||||
- [x] Compare baseline vs post-change metrics after each phase and record pass/fail decisions.
|
||||
- Evidence and gate outcomes captured in `phase-metrics-2026-04-20.md` (baseline delta table + pass/fail decisions + benchmark snapshot).
|
||||
|
||||
### 6. Rollout and Documentation
|
||||
- [ ] Update operator docs for new settings and default behavior.
|
||||
- [ ] Document compatibility-mode lifecycle and criteria to disable legacy table generation.
|
||||
- [ ] Document benchmark method/results and default-path decision record (Go vs SQL).
|
||||
- [ ] Publish a short migration runbook for staged rollout, rollback triggers, and repair workflows.
|
||||
- [x] Update operator docs for new settings and default behavior.
|
||||
- [x] Document compatibility-mode lifecycle and criteria to disable legacy table generation.
|
||||
- [x] Document benchmark method/results and default-path decision record (Go vs SQL).
|
||||
- [x] Publish a short migration runbook for staged rollout, rollback triggers, and repair workflows.
|
||||
- Completed in `README.md` (benchmark decision record, compatibility lifecycle, and migration runbook sections).
|
||||
|
||||
## Test Plan
|
||||
|
||||
|
||||
+131
-6
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"vctp/internal/auth"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
const (
|
||||
authLoginFailureMessage = "invalid username or password"
|
||||
authLoginRequestTimeout = 30 * time.Second
|
||||
maxDebugLogListItems = 25
|
||||
)
|
||||
|
||||
type ldapAuthenticator interface {
|
||||
@@ -78,10 +80,23 @@ func (h *Handler) AuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONError(w, http.StatusBadRequest, "username and password are required")
|
||||
return
|
||||
}
|
||||
audit.LogAuthEvent(h.Logger, r, "login", "observe",
|
||||
"reason", "ldap_authentication_start",
|
||||
"username", username,
|
||||
"ldap_bind_address", cfg.LDAPBindAddress,
|
||||
"ldap_base_dn", cfg.LDAPBaseDN,
|
||||
"ldap_user_base_dn", cfg.LDAPUserBaseDN,
|
||||
"ldap_group_requirements", limitStrings(cfg.LDAPGroups, maxDebugLogListItems),
|
||||
"auth_group_role_mapping_keys", limitStrings(sortedStringMapKeys(cfg.AuthGroupRoleMappings), maxDebugLogListItems),
|
||||
"ldap_insecure", cfg.LDAPInsecure,
|
||||
"ldap_disable_validation", cfg.LDAPDisableValidation,
|
||||
"ldap_trust_cert_configured", strings.TrimSpace(cfg.LDAPTrustCertFile) != "",
|
||||
)
|
||||
|
||||
ldapAuth, err := newLDAPAuthenticator(auth.LDAPConfig{
|
||||
BindAddress: cfg.LDAPBindAddress,
|
||||
BaseDN: cfg.LDAPBaseDN,
|
||||
UserBaseDN: cfg.LDAPUserBaseDN,
|
||||
TrustCertFile: cfg.LDAPTrustCertFile,
|
||||
DisableValidation: cfg.LDAPDisableValidation,
|
||||
Insecure: cfg.LDAPInsecure,
|
||||
@@ -96,26 +111,90 @@ func (h *Handler) AuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ctx, cancel := withRequestTimeout(r, authLoginRequestTimeout)
|
||||
defer cancel()
|
||||
ldapAuthStartedAt := time.Now()
|
||||
identity, err := ldapAuth.AuthenticateAndFetchGroups(ctx, username, password)
|
||||
ldapAuthDuration := time.Since(ldapAuthStartedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrLDAPInvalidCredentials) {
|
||||
audit.LogAuthEvent(h.Logger, r, "login", "deny", "reason", "invalid_credentials", "username", username)
|
||||
audit.LogAuthEvent(h.Logger, r, "login", "deny",
|
||||
"reason", "invalid_credentials",
|
||||
"username", username,
|
||||
"ldap_bind_address", cfg.LDAPBindAddress,
|
||||
"ldap_base_dn", cfg.LDAPBaseDN,
|
||||
"ldap_auth_total_duration_ms", ldapAuthDuration.Milliseconds(),
|
||||
"error", err,
|
||||
)
|
||||
writeJSONError(w, http.StatusUnauthorized, authLoginFailureMessage)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
audit.LogAuthEvent(h.Logger, r, "login", "deny", "reason", "ldap_timeout", "username", username, "error", err)
|
||||
audit.LogAuthEvent(h.Logger, r, "login", "deny",
|
||||
"reason", "ldap_timeout",
|
||||
"username", username,
|
||||
"ldap_bind_address", cfg.LDAPBindAddress,
|
||||
"ldap_base_dn", cfg.LDAPBaseDN,
|
||||
"timeout_seconds", authLoginRequestTimeout.Seconds(),
|
||||
"ldap_auth_total_duration_ms", ldapAuthDuration.Milliseconds(),
|
||||
"error", err,
|
||||
)
|
||||
writeJSONError(w, http.StatusUnauthorized, authLoginFailureMessage)
|
||||
return
|
||||
}
|
||||
audit.LogAuthEvent(h.Logger, r, "login", "deny", "reason", "ldap_authentication_failed", "username", username, "error", err)
|
||||
audit.LogAuthEvent(h.Logger, r, "login", "deny",
|
||||
"reason", "ldap_authentication_failed",
|
||||
"username", username,
|
||||
"ldap_bind_address", cfg.LDAPBindAddress,
|
||||
"ldap_base_dn", cfg.LDAPBaseDN,
|
||||
"ldap_auth_total_duration_ms", ldapAuthDuration.Milliseconds(),
|
||||
"error", err,
|
||||
)
|
||||
writeJSONError(w, http.StatusUnauthorized, authLoginFailureMessage)
|
||||
return
|
||||
}
|
||||
audit.LogAuthEvent(h.Logger, r, "login", "observe",
|
||||
"reason", "ldap_authentication_succeeded",
|
||||
"username", username,
|
||||
"ldap_identity_username", identity.Username,
|
||||
"ldap_user_dn", identity.UserDN,
|
||||
"ldap_group_count", len(identity.Groups),
|
||||
"ldap_groups", limitStrings(identity.Groups, maxDebugLogListItems),
|
||||
"ldap_auth_total_duration_ms", ldapAuthDuration.Milliseconds(),
|
||||
"ldap_bind_duration_ms", identity.BindDuration.Milliseconds(),
|
||||
"ldap_user_lookup_duration_ms", identity.UserLookupDuration.Milliseconds(),
|
||||
"ldap_group_lookup_duration_ms", identity.GroupMembershipLookupDuration.Milliseconds(),
|
||||
"ldap_diagnostics", limitStrings(identity.Diagnostics, maxDebugLogListItems),
|
||||
)
|
||||
|
||||
roles := auth.ResolveRoles(identity.Groups, cfg.AuthGroupRoleMappings)
|
||||
if !auth.HasAnyGroup(identity.Groups, cfg.LDAPGroups) || len(roles) == 0 {
|
||||
audit.LogAuthEvent(h.Logger, r, "login", "deny", "reason", "group_or_role_denied", "username", username, "group_count", len(identity.Groups), "resolved_roles", roles)
|
||||
hasRequiredGroup := auth.HasAnyGroup(identity.Groups, cfg.LDAPGroups)
|
||||
audit.LogAuthEvent(h.Logger, r, "login", "observe",
|
||||
"reason", "authorization_evaluation",
|
||||
"username", username,
|
||||
"has_required_group", hasRequiredGroup,
|
||||
"required_groups", limitStrings(cfg.LDAPGroups, maxDebugLogListItems),
|
||||
"user_groups", limitStrings(identity.Groups, maxDebugLogListItems),
|
||||
"resolved_roles", roles,
|
||||
"ldap_auth_total_duration_ms", ldapAuthDuration.Milliseconds(),
|
||||
"ldap_bind_duration_ms", identity.BindDuration.Milliseconds(),
|
||||
"ldap_user_lookup_duration_ms", identity.UserLookupDuration.Milliseconds(),
|
||||
"ldap_group_lookup_duration_ms", identity.GroupMembershipLookupDuration.Milliseconds(),
|
||||
"auth_group_role_mapping_keys", limitStrings(sortedStringMapKeys(cfg.AuthGroupRoleMappings), maxDebugLogListItems),
|
||||
)
|
||||
if !hasRequiredGroup || len(roles) == 0 {
|
||||
audit.LogAuthEvent(h.Logger, r, "login", "deny",
|
||||
"reason", "group_or_role_denied",
|
||||
"username", username,
|
||||
"group_count", len(identity.Groups),
|
||||
"has_required_group", hasRequiredGroup,
|
||||
"required_groups", limitStrings(cfg.LDAPGroups, maxDebugLogListItems),
|
||||
"user_groups", limitStrings(identity.Groups, maxDebugLogListItems),
|
||||
"resolved_roles", roles,
|
||||
"ldap_auth_total_duration_ms", ldapAuthDuration.Milliseconds(),
|
||||
"ldap_bind_duration_ms", identity.BindDuration.Milliseconds(),
|
||||
"ldap_user_lookup_duration_ms", identity.UserLookupDuration.Milliseconds(),
|
||||
"ldap_group_lookup_duration_ms", identity.GroupMembershipLookupDuration.Milliseconds(),
|
||||
"ldap_diagnostics", limitStrings(identity.Diagnostics, maxDebugLogListItems),
|
||||
)
|
||||
writeJSONError(w, http.StatusUnauthorized, authLoginFailureMessage)
|
||||
return
|
||||
}
|
||||
@@ -138,7 +217,7 @@ func (h *Handler) AuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if subject == "" {
|
||||
subject = username
|
||||
}
|
||||
token, claims, err := jwtSvc.IssueToken(subject, roles, identity.Groups)
|
||||
token, claims, err := jwtSvc.IssueToken(subject, roles, nil)
|
||||
if err != nil {
|
||||
h.Logger.Error("failed to issue auth token", "username", username, "error", err)
|
||||
audit.LogAuthEvent(h.Logger, r, "login", "error", "reason", "token_issue_failed", "username", username, "error", err)
|
||||
@@ -191,3 +270,49 @@ func (h *Handler) AuthMe(w http.ResponseWriter, r *http.Request) {
|
||||
TokenID: claims.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func sortedStringMapKeys(values map[string]string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func limitStrings(values []string, maxItems int) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
if maxItems <= 0 || len(values) <= maxItems {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := make([]string, 0, maxItems+1)
|
||||
for _, value := range values[:maxItems] {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
out = append(out, "...")
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
"vctp/db"
|
||||
"vctp/db/queries"
|
||||
"vctp/server/models"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type snapshotRepairTestDatabase struct {
|
||||
dbConn *sqlx.DB
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (d *snapshotRepairTestDatabase) DB() *sqlx.DB { return d.dbConn }
|
||||
func (d *snapshotRepairTestDatabase) Queries() db.Querier { return queries.New(d.dbConn.DB) }
|
||||
func (d *snapshotRepairTestDatabase) Logger() *slog.Logger {
|
||||
if d.logger != nil {
|
||||
return d.logger
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
func (d *snapshotRepairTestDatabase) Close() error { return d.dbConn.Close() }
|
||||
|
||||
func newSnapshotRepairTestDB(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
dbConn, err := sqlx.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite test db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = dbConn.Close()
|
||||
})
|
||||
return dbConn
|
||||
}
|
||||
|
||||
func TestSnapshotRepairSuite_RebuildsRegistryTotalsAndLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dbConn := newSnapshotRepairTestDB(t)
|
||||
logger := newTestLogger()
|
||||
h := &Handler{
|
||||
Logger: logger,
|
||||
Database: &snapshotRepairTestDatabase{dbConn: dbConn, logger: logger},
|
||||
}
|
||||
|
||||
dayStart := time.Date(2026, time.March, 16, 0, 0, 0, 0, time.UTC)
|
||||
hourlyTs := dayStart.Add(2 * time.Hour).Unix()
|
||||
hourlyTable := fmt.Sprintf("inventory_hourly_%d", hourlyTs)
|
||||
dailyTable := fmt.Sprintf("inventory_daily_summary_%s", dayStart.Format("20060102"))
|
||||
monthlyTable := fmt.Sprintf("inventory_monthly_summary_%s", dayStart.Format("200601"))
|
||||
|
||||
if err := db.EnsureSnapshotTable(ctx, dbConn, hourlyTable); err != nil {
|
||||
t.Fatalf("failed to ensure hourly table: %v", err)
|
||||
}
|
||||
if err := db.EnsureSummaryTable(ctx, dbConn, dailyTable); err != nil {
|
||||
t.Fatalf("failed to ensure daily summary table: %v", err)
|
||||
}
|
||||
if err := db.EnsureSummaryTable(ctx, dbConn, monthlyTable); err != nil {
|
||||
t.Fatalf("failed to ensure monthly summary table: %v", err)
|
||||
}
|
||||
|
||||
if _, err := dbConn.ExecContext(ctx, fmt.Sprintf(`
|
||||
INSERT INTO %s (
|
||||
"Name","Vcenter","VmId","VmUuid","CreationTime","DeletionTime","ResourcePool","Datacenter","Cluster","Folder",
|
||||
"ProvisionedDisk","VcpuCount","RamGB","IsTemplate","PoweredOn","SrmPlaceholder","SnapshotTime"
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`, hourlyTable),
|
||||
"vm-a", "vc-a", "vm-a", "uuid-a", dayStart.Add(-24*time.Hour).Unix(), int64(0), "Tin", "dc-a", "cluster-a", "/prod",
|
||||
100.0, int64(2), int64(8), "FALSE", "TRUE", "FALSE", hourlyTs,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to seed hourly table: %v", err)
|
||||
}
|
||||
|
||||
if _, err := dbConn.ExecContext(ctx, fmt.Sprintf(`
|
||||
INSERT INTO %s (
|
||||
"Name","Vcenter","VmId","VmUuid","CreationTime","DeletionTime","ResourcePool","Datacenter","Cluster","Folder",
|
||||
"ProvisionedDisk","VcpuCount","RamGB","IsTemplate","PoweredOn","SrmPlaceholder","SnapshotTime","SamplesPresent",
|
||||
"AvgVcpuCount","AvgRamGB","AvgProvisionedDisk","AvgIsPresent","PoolTinPct","PoolBronzePct","PoolSilverPct","PoolGoldPct","Tin","Bronze","Silver","Gold"
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`, dailyTable),
|
||||
"vm-a", "vc-a", "vm-a", "uuid-a", int64(0), int64(0), "Tin", "dc-a", "cluster-a", "/prod",
|
||||
100.0, int64(2), int64(8), "FALSE", "TRUE", "FALSE", int64(0), int64(1),
|
||||
2.0, 8.0, 100.0, 1.0, 100.0, 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, 0.0,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to seed daily summary table: %v", err)
|
||||
}
|
||||
|
||||
if _, err := dbConn.ExecContext(ctx, fmt.Sprintf(`
|
||||
INSERT INTO %s (
|
||||
"Name","Vcenter","VmId","VmUuid","CreationTime","DeletionTime","ResourcePool","Datacenter","Cluster","Folder",
|
||||
"ProvisionedDisk","VcpuCount","RamGB","IsTemplate","PoweredOn","SrmPlaceholder","SnapshotTime","SamplesPresent",
|
||||
"AvgVcpuCount","AvgRamGB","AvgProvisionedDisk","AvgIsPresent","PoolTinPct","PoolBronzePct","PoolSilverPct","PoolGoldPct","Tin","Bronze","Silver","Gold"
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`, monthlyTable),
|
||||
"vm-a", "vc-a", "vm-a", "uuid-a", int64(0), int64(0), "Tin", "dc-a", "cluster-a", "/prod",
|
||||
100.0, int64(2), int64(8), "FALSE", "TRUE", "FALSE", dayStart.Unix(), int64(1),
|
||||
2.0, 8.0, 100.0, 1.0, 100.0, 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, 0.0,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to seed monthly summary table: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/snapshots/repair/all", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
h.SnapshotRepairSuite(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var payload models.SnapshotRepairSuiteResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if payload.Status != "OK" {
|
||||
t.Fatalf("unexpected repair suite status: %q", payload.Status)
|
||||
}
|
||||
|
||||
dailyRepaired, err := strconv.Atoi(payload.DailyRepaired)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse daily_repaired: %v", err)
|
||||
}
|
||||
if dailyRepaired < 1 {
|
||||
t.Fatalf("expected at least one daily table repaired, got %d", dailyRepaired)
|
||||
}
|
||||
monthlyRefined, err := strconv.Atoi(payload.MonthlyRefined)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse monthly_refined: %v", err)
|
||||
}
|
||||
if monthlyRefined < 1 {
|
||||
t.Fatalf("expected at least one monthly table refined, got %d", monthlyRefined)
|
||||
}
|
||||
monthlyFailed, err := strconv.Atoi(payload.MonthlyFailed)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse monthly_failed: %v", err)
|
||||
}
|
||||
if monthlyFailed != 0 {
|
||||
t.Fatalf("expected monthly_failed=0, got %d", monthlyFailed)
|
||||
}
|
||||
|
||||
assertSnapshotRegistryTypeCount(t, ctx, dbConn, "hourly", 1)
|
||||
assertSnapshotRegistryTypeCount(t, ctx, dbConn, "daily", 1)
|
||||
assertSnapshotRegistryTypeCount(t, ctx, dbConn, "monthly", 1)
|
||||
|
||||
var totalsRows int
|
||||
if err := dbConn.GetContext(ctx, &totalsRows, `SELECT COUNT(1) FROM vcenter_totals WHERE "Vcenter" = ?`, "vc-a"); err != nil {
|
||||
t.Fatalf("failed to query vcenter_totals: %v", err)
|
||||
}
|
||||
if totalsRows < 1 {
|
||||
t.Fatalf("expected vcenter_totals to be backfilled, got %d rows", totalsRows)
|
||||
}
|
||||
|
||||
var dailySnapshotTime int64
|
||||
if err := dbConn.GetContext(ctx, &dailySnapshotTime, fmt.Sprintf(`SELECT COALESCE("SnapshotTime",0) FROM %s WHERE "Vcenter" = ? AND "VmId" = ?`, dailyTable), "vc-a", "vm-a"); err != nil {
|
||||
t.Fatalf("failed to query repaired daily snapshot time: %v", err)
|
||||
}
|
||||
if dailySnapshotTime == 0 {
|
||||
t.Fatal("expected repaired daily summary SnapshotTime to be backfilled")
|
||||
}
|
||||
}
|
||||
|
||||
func assertSnapshotRegistryTypeCount(t *testing.T, ctx context.Context, dbConn *sqlx.DB, snapshotType string, want int) {
|
||||
t.Helper()
|
||||
var got int
|
||||
if err := dbConn.GetContext(ctx, &got, `SELECT COUNT(1) FROM snapshot_registry WHERE snapshot_type = ?`, snapshotType); err != nil {
|
||||
t.Fatalf("failed to query snapshot_registry for type %s: %v", snapshotType, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("unexpected snapshot_registry count for %s: got %d want %d", snapshotType, got, want)
|
||||
}
|
||||
}
|
||||
@@ -162,3 +162,105 @@ func TestSwaggerJSONDefaultsToHTTPWhenTLSDisabled(t *testing.T) {
|
||||
t.Fatalf("unexpected schemes: got %v want %v", spec.Schemes, []string{"http"})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedStylesExposeThemeTokensAndResponsiveAccessibilityRules(t *testing.T) {
|
||||
app := testRouter(t, testRouterSettings(t, false))
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/css/web3.css", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
app.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
css := rr.Body.String()
|
||||
|
||||
assertContainsAll(t, css, []string{
|
||||
":root {",
|
||||
"--theme_text_primary:",
|
||||
"--theme_accent_blue:",
|
||||
"--theme_focus_outline:",
|
||||
".web2-shell-wide {",
|
||||
".web2-page-title {",
|
||||
"font-size: clamp(",
|
||||
".web2-table-shell {",
|
||||
"overflow-x: auto;",
|
||||
".web2-input:focus-visible {",
|
||||
"a:focus-visible,",
|
||||
"@media (max-width: 900px)",
|
||||
".web2-actions .web2-button {",
|
||||
"min-width: 520px;",
|
||||
"@media (min-width: 1500px)",
|
||||
"@media (min-width: 780px)",
|
||||
"@media (min-width: 1024px)",
|
||||
})
|
||||
}
|
||||
|
||||
func TestDashboardAuthGuidanceMatchesRouteProtection(t *testing.T) {
|
||||
app := testRouter(t, testRouterSettings(t, false))
|
||||
|
||||
homeReq := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
homeRR := httptest.NewRecorder()
|
||||
app.ServeHTTP(homeRR, homeReq)
|
||||
if homeRR.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, homeRR.Code)
|
||||
}
|
||||
homeBody := homeRR.Body.String()
|
||||
assertContainsAll(t, homeBody, []string{
|
||||
"POST /api/auth/login",
|
||||
"Authorization: Bearer <token>",
|
||||
"viewer",
|
||||
"admin",
|
||||
"UI pages and <code class=\"web2-code\">/metrics</code> remain public.",
|
||||
})
|
||||
|
||||
for _, path := range []string{"/swagger/", "/metrics", "/vm/trace"} {
|
||||
t.Run("public "+path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
app.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d for %s, got %d", http.StatusOK, path, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protectedReq := httptest.NewRequest(http.MethodGet, "/api/report/snapshot", nil)
|
||||
protectedRR := httptest.NewRecorder()
|
||||
app.ServeHTTP(protectedRR, protectedReq)
|
||||
if protectedRR.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status %d for protected route, got %d", http.StatusUnauthorized, protectedRR.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVmTraceFormUsesLabelledInputsAndKeyboardFriendlyControls(t *testing.T) {
|
||||
app := testRouter(t, testRouterSettings(t, false))
|
||||
req := httptest.NewRequest(http.MethodGet, "/vm/trace", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
app.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
|
||||
assertContainsAll(t, body, []string{
|
||||
`<form method="get" action="/vm/trace" class="web2-form-grid">`,
|
||||
`<label class="web2-label" for="vm_id">VM ID</label>`,
|
||||
`<input class="web2-input" type="text" id="vm_id" name="vm_id"`,
|
||||
`<label class="web2-label" for="vm_uuid">VM UUID</label>`,
|
||||
`<input class="web2-input" type="text" id="vm_uuid" name="vm_uuid"`,
|
||||
`<label class="web2-label" for="name">Name</label>`,
|
||||
`<input class="web2-input" type="text" id="name" name="name"`,
|
||||
`<button class="web3-button active" type="submit">Load VM Trace</button>`,
|
||||
`<a class="web3-button" href="/vm/trace">Clear</a>`,
|
||||
})
|
||||
}
|
||||
|
||||
func assertContainsAll(t *testing.T, body string, snippets []string) {
|
||||
t.Helper()
|
||||
for _, snippet := range snippets {
|
||||
if !strings.Contains(body, snippet) {
|
||||
t.Fatalf("expected response body to contain %q", snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user