2026-05-29 14:43:34 +08:00
|
|
|
package sqlite
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-05-29 15:50:28 +08:00
|
|
|
"database/sql"
|
2026-05-29 14:43:34 +08:00
|
|
|
"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 := ""
|
2026-05-29 15:50:28 +08:00
|
|
|
shadowHostID := ""
|
2026-05-29 14:43:34 +08:00
|
|
|
for _, resource := range resources {
|
|
|
|
|
if strings.TrimSpace(resource.ResourceType) == "group" {
|
|
|
|
|
shadowGroupID = strings.TrimSpace(resource.HostResourceID)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-29 15:50:28 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-29 14:43:34 +08:00
|
|
|
|
|
|
|
|
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]
|
|
|
|
|
}
|
2026-05-30 14:42:51 +08:00
|
|
|
accountStatus, probeStatus := deriveProviderAccountState(batch, len(accountResources), match)
|
2026-05-29 14:43:34 +08:00
|
|
|
row := ProviderAccount{
|
|
|
|
|
HostID: batch.HostID,
|
|
|
|
|
ProviderID: batch.ProviderID,
|
2026-05-29 15:50:28 +08:00
|
|
|
RouteID: matchedRoute.RouteID,
|
2026-05-29 14:43:34 +08:00
|
|
|
ShadowGroupID: shadowGroupID,
|
|
|
|
|
HostAccountID: hostAccountID,
|
|
|
|
|
KeyFingerprint: fallbackString(match.KeyFingerprint, "legacy:"+hostAccountID),
|
|
|
|
|
AccountName: fallbackString(resource.ResourceName, hostAccountID),
|
2026-05-30 14:42:51 +08:00
|
|
|
AccountStatus: accountStatus,
|
|
|
|
|
LastProbeStatus: probeStatus,
|
2026-05-29 14:43:34 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 15:50:28 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-29 15:58:37 +08:00
|
|
|
if isAmbiguousProviderAccountRouteBinding(err) {
|
|
|
|
|
return LogicalGroupRoute{}, sql.ErrNoRows
|
|
|
|
|
}
|
2026-05-29 15:50:28 +08:00
|
|
|
return LogicalGroupRoute{}, fmt.Errorf("resolve logical route for provider account shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
|
|
|
|
}
|
|
|
|
|
return route, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 14:43:34 +08:00
|
|
|
type legacyBatchAccountProjection struct {
|
2026-05-30 14:42:51 +08:00
|
|
|
KeyFingerprint string
|
|
|
|
|
AccountStatus string
|
|
|
|
|
ValidationStatus string
|
|
|
|
|
ProbeStatus string
|
|
|
|
|
AccountID string
|
|
|
|
|
ProbeAdvisory bool
|
|
|
|
|
SmokeModelSeen bool
|
2026-05-29 14:43:34 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-05-30 14:42:51 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-29 14:43:34 +08:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 14:42:51 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 14:43:34 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-29 15:58:37 +08:00
|
|
|
|
|
|
|
|
func isAmbiguousProviderAccountRouteBinding(err error) bool {
|
|
|
|
|
if err == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return strings.Contains(err.Error(), "multiple logical group routes match shadow binding")
|
|
|
|
|
}
|