package app import ( "context" "encoding/json" "errors" "fmt" "log" "strings" "time" "sub2api-cn-relay-manager/internal/host/sub2api" "sub2api-cn-relay-manager/internal/pack" "sub2api-cn-relay-manager/internal/provision" "sub2api-cn-relay-manager/internal/reconcile" "sub2api-cn-relay-manager/internal/store/sqlite" "sub2api-cn-relay-manager/internal/worker" ) const sqliteTimestampLayout = "2006-01-02 15:04:05" func runReconcileBackgroundScheduler(ctx context.Context, sqliteDSN string, interval time.Duration) { if interval <= 0 { return } worker.NewRunner( []worker.Job{reconcileSweepJob{sqliteDSN: sqliteDSN, interval: interval}}, interval, log.Printf, ).Start(ctx) } type reconcileSweepJob struct { sqliteDSN string interval time.Duration } func (j reconcileSweepJob) Name() string { return "reconcile background scheduler" } func (j reconcileSweepJob) Run(ctx context.Context) error { store, err := sqlite.Open(ctx, j.sqliteDSN) if err != nil { return err } defer store.Close() return runReconcileBackgroundSweep(ctx, store, j.interval, time.Now()) } func runReconcileBackgroundSweep(ctx context.Context, store *sqlite.DB, interval time.Duration, now time.Time) error { if store == nil { return fmt.Errorf("store is required") } candidates, err := store.ImportBatches().ListLatestReconcilable(ctx) if err != nil { return err } var errs []error for _, batch := range candidates { if ctx.Err() != nil { return ctx.Err() } lastRun, err := latestReconcileRunForBatch(ctx, store, batch.ProviderID, batch.HostID) if err != nil { errs = append(errs, fmt.Errorf("load latest reconcile run for batch %d: %w", batch.ID, err)) continue } if !reconcileRunDue(now, lastRun, interval) { continue } if err := runReconcileCandidate(ctx, store, batch); err != nil { errs = append(errs, fmt.Errorf("run reconcile for batch %d: %w", batch.ID, err)) } } return errors.Join(errs...) } func latestReconcileRunForBatch(ctx context.Context, store *sqlite.DB, providerID, hostID int64) (*sqlite.ReconcileRun, error) { runs, err := store.ReconcileRuns().GetByProviderIDAndHostID(ctx, providerID, hostID) if err != nil { return nil, err } if len(runs) == 0 { return nil, nil } return &runs[0], nil } func reconcileRunDue(now time.Time, run *sqlite.ReconcileRun, interval time.Duration) bool { if run == nil || interval <= 0 { return true } lastRunAt, err := time.ParseInLocation(sqliteTimestampLayout, strings.TrimSpace(run.CreatedAt), time.UTC) if err != nil { return true } return now.Sub(lastRunAt) >= interval } func runReconcileCandidate(ctx context.Context, store *sqlite.DB, batch sqlite.ImportBatch) error { hostRow, err := store.Hosts().GetByID(ctx, batch.HostID) if err != nil { return err } packRow, err := store.Packs().GetByID(ctx, batch.PackID) if err != nil { return err } providerRow, err := store.Providers().GetByID(ctx, batch.ProviderID) if err != nil { return err } accessClosures, err := store.AccessClosures().GetByBatchID(ctx, batch.ID) if err != nil { return err } accessProbeAPIKey, err := reconcileProbeAPIKey(ctx, store, hostRow, batch, accessClosures) if err != nil { return err } loadedPack, err := storedLoadedPack(packRow) if err != nil { return err } providerManifest, err := storedProviderManifest(providerRow) if err != nil { return err } client, err := newSub2APIClient(hostRow.BaseURL, authFromStoredHost(hostRow)) if err != nil { return err } _, err = reconcile.NewService(store, client).Reconcile(ctx, reconcile.Request{ HostID: hostRow.HostID, HostBaseURL: hostRow.BaseURL, AccessProbeAPIKey: accessProbeAPIKey, Pack: loadedPack, Provider: providerManifest, }) return err } func reconcileProbeAPIKey(ctx context.Context, store *sqlite.DB, hostRow sqlite.Host, batch sqlite.ImportBatch, accessClosures []sqlite.AccessClosureRecord) (string, error) { if len(accessClosures) == 0 { return "", fmt.Errorf("access closure not found for batch %d", batch.ID) } latestClosure := accessClosures[len(accessClosures)-1] switch strings.TrimSpace(latestClosure.ClosureType) { case provision.AccessModeSelfService: details := parseAccessClosureDetails(latestClosure.DetailsJSON) apiKey, _ := details["access_api_key"].(string) if strings.TrimSpace(apiKey) == "" { apiKey, _ = details["probe_api_key"].(string) } if strings.TrimSpace(apiKey) == "" { return "", fmt.Errorf("self_service access closure missing probe api key") } return strings.TrimSpace(apiKey), nil case provision.AccessModeSubscription: details := parseAccessClosureDetails(latestClosure.DetailsJSON) subscriptionUsers := parseJSONStringArray(details["subscription_users"]) if len(subscriptionUsers) == 0 { return "", fmt.Errorf("subscription access closure missing subscription_users") } subscriptionDays := parseJSONInt(details["subscription_days"]) groupID, err := resolveManagedResourceHostIDByBatch(ctx, store, batch.ID, "group") if err != nil { return "", err } client, err := newSub2APIClient(hostRow.BaseURL, authFromStoredHost(hostRow)) if err != nil { return "", err } accessRef, err := client.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{ UserSelector: subscriptionUsers[0], GroupID: groupID, }) if err != nil { return "", err } userID := strings.TrimSpace(accessRef.UserID) if userID == "" { userID = subscriptionUsers[0] } if subscriptionDays > 0 { if _, err := client.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{ UserID: userID, GroupID: groupID, DurationDays: subscriptionDays, }); err != nil { return "", err } } if strings.TrimSpace(accessRef.APIKey) == "" { return "", fmt.Errorf("subscription access api key is empty") } return strings.TrimSpace(accessRef.APIKey), nil default: return "", fmt.Errorf("unsupported access closure type %q", latestClosure.ClosureType) } } func parseAccessClosureDetails(raw string) map[string]any { payload := map[string]any{} if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil { return map[string]any{} } return payload } func parseJSONStringArray(raw any) []string { values, ok := raw.([]any) if !ok { return nil } result := make([]string, 0, len(values)) for _, value := range values { text, ok := value.(string) if !ok { continue } if trimmed := strings.TrimSpace(text); trimmed != "" { result = append(result, trimmed) } } return result } func parseJSONInt(raw any) int { switch value := raw.(type) { case float64: return int(value) case int: return value default: return 0 } } func storedLoadedPack(packRow sqlite.Pack) (pack.LoadedPack, error) { manifest := pack.Manifest{} if trimmed := strings.TrimSpace(packRow.ManifestJSON); trimmed != "" && trimmed != "{}" { if err := json.Unmarshal([]byte(trimmed), &manifest); err != nil { return pack.LoadedPack{}, fmt.Errorf("decode stored pack manifest: %w", err) } } if strings.TrimSpace(manifest.PackID) == "" { manifest.PackID = strings.TrimSpace(packRow.PackID) } if strings.TrimSpace(manifest.Version) == "" { manifest.Version = strings.TrimSpace(packRow.Version) } if strings.TrimSpace(manifest.Vendor) == "" { manifest.Vendor = strings.TrimSpace(packRow.Vendor) } if strings.TrimSpace(manifest.TargetHost) == "" { manifest.TargetHost = strings.TrimSpace(packRow.TargetHost) } if strings.TrimSpace(manifest.MinHostVersion) == "" { manifest.MinHostVersion = strings.TrimSpace(packRow.MinHostVersion) } if strings.TrimSpace(manifest.MaxHostVersion) == "" { manifest.MaxHostVersion = strings.TrimSpace(packRow.MaxHostVersion) } return pack.LoadedPack{Manifest: manifest, Checksum: strings.TrimSpace(packRow.Checksum)}, nil } func storedProviderManifest(providerRow sqlite.Provider) (pack.ProviderManifest, error) { provider := pack.ProviderManifest{} if trimmed := strings.TrimSpace(providerRow.ManifestJSON); trimmed != "" && trimmed != "{}" { if err := json.Unmarshal([]byte(trimmed), &provider); err != nil { return pack.ProviderManifest{}, fmt.Errorf("decode stored provider manifest: %w", err) } } if strings.TrimSpace(provider.ProviderID) == "" { provider.ProviderID = strings.TrimSpace(providerRow.ProviderID) } if strings.TrimSpace(provider.DisplayName) == "" { provider.DisplayName = strings.TrimSpace(providerRow.DisplayName) } if strings.TrimSpace(provider.BaseURL) == "" { provider.BaseURL = strings.TrimSpace(providerRow.BaseURL) } if strings.TrimSpace(provider.Platform) == "" { provider.Platform = strings.TrimSpace(providerRow.Platform) } if strings.TrimSpace(provider.AccountType) == "" { provider.AccountType = strings.TrimSpace(providerRow.AccountType) } if strings.TrimSpace(provider.SmokeTestModel) == "" { provider.SmokeTestModel = strings.TrimSpace(providerRow.SmokeTestModel) } return provider, nil } func resolveManagedResourceHostIDByBatch(ctx context.Context, store *sqlite.DB, batchID int64, resourceType string) (string, error) { resources, err := store.ManagedResources().GetByBatchID(ctx, batchID) if err != nil { return "", err } resourceType = strings.TrimSpace(resourceType) for _, resource := range resources { if strings.TrimSpace(resource.ResourceType) == resourceType && strings.TrimSpace(resource.HostResourceID) != "" { return strings.TrimSpace(resource.HostResourceID), nil } } return "", fmt.Errorf("managed resource %q not found for batch %d", resourceType, batchID) }