package sqlite import ( "context" "database/sql" "encoding/json" "fmt" "strings" "time" ) const providerAccountDeprecatedMissingReason = "missing_from_latest_batch" func SyncProviderAccountsFromLatestImportBatches(ctx context.Context, store *DB) error { if store == nil { return fmt.Errorf("store is required") } batches, err := store.ImportBatches().ListLatestReconcilable(ctx) if err != nil { return err } for _, batch := range batches { if err := SyncProviderAccountsFromImportBatch(ctx, store, batch.ID); err != nil { return err } } return nil } func SyncProviderAccountsFromImportBatch(ctx context.Context, store *DB, batchID int64) error { if store == nil { return fmt.Errorf("store is required") } if batchID <= 0 { return fmt.Errorf("batch_id is required") } batch, err := store.ImportBatches().GetByID(ctx, batchID) if err != nil { return fmt.Errorf("get import batch %d: %w", batchID, err) } switch strings.TrimSpace(batch.BatchStatus) { case "succeeded", "partially_succeeded": default: return nil } resources, err := store.ManagedResources().GetByBatchID(ctx, batchID) if err != nil { return fmt.Errorf("get managed resources for batch %d: %w", batchID, err) } items, err := store.ImportBatchItems().GetByBatchID(ctx, batchID) if err != nil { return fmt.Errorf("get import batch items for batch %d: %w", batchID, err) } nowText := time.Now().UTC().Format(time.RFC3339) shadowGroupID := "" shadowHostID := "" for _, resource := range resources { if strings.TrimSpace(resource.ResourceType) == "group" { shadowGroupID = strings.TrimSpace(resource.HostResourceID) break } } hostRow, err := store.Hosts().GetByID(ctx, batch.HostID) if err == nil { shadowHostID = strings.TrimSpace(hostRow.HostID) } matchedRoute, routeErr := resolveProviderAccountRouteBinding(ctx, store, shadowHostID, shadowGroupID) if routeErr != nil && routeErr != sql.ErrNoRows { return routeErr } accountResources := make([]ManagedResource, 0) for _, resource := range resources { if strings.TrimSpace(resource.ResourceType) == "account" { accountResources = append(accountResources, resource) } } itemByAccountID, unmatchedItems := indexBatchItemsByAccountID(items) keepAccountIDs := make([]string, 0, len(accountResources)) for index, resource := range accountResources { hostAccountID := strings.TrimSpace(resource.HostResourceID) if hostAccountID == "" { continue } keepAccountIDs = append(keepAccountIDs, hostAccountID) match, ok := itemByAccountID[hostAccountID] if !ok && index < len(unmatchedItems) { match = unmatchedItems[index] } accountStatus, probeStatus := deriveProviderAccountState(batch, len(accountResources), match) row := ProviderAccount{ HostID: batch.HostID, ProviderID: batch.ProviderID, RouteID: matchedRoute.RouteID, ShadowGroupID: shadowGroupID, HostAccountID: hostAccountID, KeyFingerprint: fallbackString(match.KeyFingerprint, "legacy:"+hostAccountID), AccountName: fallbackString(resource.ResourceName, hostAccountID), AccountStatus: accountStatus, LastProbeStatus: probeStatus, LastProbeAt: nowText, } if existing, err := store.ProviderAccounts().GetByHostIDAndAccountID(ctx, batch.HostID, hostAccountID); err == nil { if strings.TrimSpace(existing.RouteID) != "" { row.RouteID = existing.RouteID } if strings.TrimSpace(existing.ShadowGroupID) != "" { row.ShadowGroupID = existing.ShadowGroupID } preserveManagedProviderAccountStatus(&row, existing) } if _, err := store.ProviderAccounts().Upsert(ctx, row); err != nil { return fmt.Errorf("upsert provider account %q from batch %d: %w", hostAccountID, batchID, err) } } if err := store.ProviderAccounts().DeprecateMissingForScope(ctx, batch.ProviderID, batch.HostID, keepAccountIDs, providerAccountDeprecatedMissingReason); err != nil { return err } return nil } func resolveProviderAccountRouteBinding(ctx context.Context, store *DB, shadowHostID, shadowGroupID string) (LogicalGroupRoute, error) { if store == nil { return LogicalGroupRoute{}, fmt.Errorf("store is required") } shadowHostID = strings.TrimSpace(shadowHostID) shadowGroupID = strings.TrimSpace(shadowGroupID) if shadowHostID == "" || shadowGroupID == "" { return LogicalGroupRoute{}, sql.ErrNoRows } route, err := store.LogicalGroupRoutes().GetByShadowBinding(ctx, shadowHostID, shadowGroupID) if err != nil { if err == sql.ErrNoRows { return LogicalGroupRoute{}, err } if isAmbiguousProviderAccountRouteBinding(err) { return LogicalGroupRoute{}, sql.ErrNoRows } return LogicalGroupRoute{}, fmt.Errorf("resolve logical route for provider account shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err) } return route, nil } type legacyBatchAccountProjection struct { KeyFingerprint string AccountStatus string ValidationStatus string ProbeStatus string AccountID string ProbeAdvisory bool SmokeModelSeen bool } func indexBatchItemsByAccountID(items []ImportBatchItem) (map[string]legacyBatchAccountProjection, []legacyBatchAccountProjection) { indexed := make(map[string]legacyBatchAccountProjection, len(items)) unmatched := make([]legacyBatchAccountProjection, 0, len(items)) for _, item := range items { projection := legacyBatchAccountProjection{ KeyFingerprint: strings.TrimSpace(item.KeyFingerprint), AccountStatus: strings.TrimSpace(item.AccountStatus), } var payload map[string]any if err := json.Unmarshal([]byte(defaultJSON(strings.TrimSpace(item.ProbeSummaryJSON), "{}")), &payload); err == nil { if value, ok := payload["probe_status"].(string); ok { projection.ProbeStatus = strings.TrimSpace(value) } if value, ok := payload["account_id"].(string); ok { projection.AccountID = strings.TrimSpace(value) } if value, ok := payload["validation_status"].(string); ok { projection.ValidationStatus = strings.TrimSpace(value) } if value, ok := payload["probe_advisory"].(bool); ok { projection.ProbeAdvisory = value } if value, ok := payload["smoke_model_seen"].(bool); ok { projection.SmokeModelSeen = value } } if projection.AccountID != "" { indexed[projection.AccountID] = projection continue } unmatched = append(unmatched, projection) } return indexed, unmatched } func providerAccountStatusFromLegacy(accountStatus string) string { switch strings.TrimSpace(accountStatus) { case "passed", "warning": return ProviderAccountStatusActive case ProviderAccountStatusDisabled: return ProviderAccountStatusDisabled case ProviderAccountStatusDeprecated: return ProviderAccountStatusDeprecated default: return ProviderAccountStatusBroken } } func deriveProviderAccountState(batch ImportBatch, accountResourceCount int, projection legacyBatchAccountProjection) (string, string) { legacyStatus := fallbackString(projection.ValidationStatus, projection.AccountStatus) probeStatus := strings.TrimSpace(projection.ProbeStatus) if projection.ProbeAdvisory || strings.EqualFold(legacyStatus, "warning") { legacyStatus = "warning" if probeStatus == "" || strings.EqualFold(probeStatus, "failed") { probeStatus = "warning" } } if importBatchAccessReady(batch.AccessStatus) && accountResourceCount == 1 && projection.SmokeModelSeen && strings.EqualFold(legacyStatus, "failed") { return ProviderAccountStatusActive, "gateway_ready" } return providerAccountStatusFromLegacy(legacyStatus), probeStatus } func importBatchAccessReady(accessStatus string) bool { switch strings.TrimSpace(accessStatus) { case "subscription_ready", "self_service_ready", "fully_ready": return true default: return false } } func fallbackString(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } return "" } func preserveManagedProviderAccountStatus(row *ProviderAccount, existing ProviderAccount) { if row == nil { return } switch strings.TrimSpace(existing.AccountStatus) { case ProviderAccountStatusDisabled: row.AccountStatus = ProviderAccountStatusDisabled row.DisabledReason = strings.TrimSpace(existing.DisabledReason) case ProviderAccountStatusDeprecated: if strings.TrimSpace(existing.DisabledReason) != providerAccountDeprecatedMissingReason { row.AccountStatus = ProviderAccountStatusDeprecated row.DisabledReason = strings.TrimSpace(existing.DisabledReason) } } } func isAmbiguousProviderAccountRouteBinding(err error) bool { if err == nil { return false } return strings.Contains(err.Error(), "multiple logical group routes match shadow binding") }