package gitops import ( "log" "strings" "time" "xorm.io/xorm" gitdomain "github.com/forgeo/forgebucket/internal/domain/git" "github.com/forgeo/forgebucket/internal/events" "github.com/forgeo/forgebucket/internal/models" ) // CheckDrift resolves the HEAD SHA of branch in the repo at repoPath and // compares it against actualSHA. Returns the resolved HEAD SHA, whether drift // exists, and any error. func CheckDrift(repoPath, branch, actualSHA string) (desiredSHA string, drifted bool, err error) { sha, err := gitdomain.RevParse(repoPath, branch) if err != nil { return "", false, err } return sha, sha != actualSHA, nil } // refToBranch strips the refs/heads/ prefix from a full git ref. // Returns "" for non-branch refs (tags, etc.). func refToBranch(ref string) string { return strings.TrimPrefix(ref, "refs/heads/") } // handlePush is called on every push.received event. For each GitOpsConfig // on the pushed repo whose branch matches, it runs a drift check. func (c *Controller) handlePush(evt events.PushEvent) { pushedBranch := refToBranch(evt.Ref) if pushedBranch == "" { return // tag push or other non-branch ref — ignore } var cfgs []models.GitOpsConfig if err := c.db.Where("repo_id = ?", evt.RepoID).Find(&cfgs); err != nil { return } for _, cfg := range cfgs { if cfg.Branch != pushedBranch { continue } c.evaluateDrift(cfg, evt.After) } } // evaluateDrift compares desiredSHA against the config's ActualSHA and takes // the appropriate action: record drift and optionally auto-sync. func (c *Controller) evaluateDrift(cfg models.GitOpsConfig, desiredSHA string) { now := time.Now().UTC() cfg.LastCheckedAt = &now cfg.DesiredSHA = desiredSHA if desiredSHA == cfg.ActualSHA { // Already in sync. cfg.SyncStatus = "synced" c.db.ID(cfg.ID).Cols("sync_status", "desired_sha", "last_checked_at").Update(&cfg) //nolint:errcheck return } // Drift detected — record and publish. log.Printf("gitops: drift on env %d: desired=%s actual=%s", cfg.EnvID, desiredSHA[:7], sha7(cfg.ActualSHA)) drift := &models.GitOpsDriftEvent{ EnvID: cfg.EnvID, RepoID: cfg.RepoID, DesiredSHA: desiredSHA, ActualSHA: cfg.ActualSHA, SyncStatus: "drifted", DetectedAt: now, } c.db.Insert(drift) //nolint:errcheck cfg.SyncStatus = "drifted" c.db.ID(cfg.ID).Cols("sync_status", "desired_sha", "last_checked_at").Update(&cfg) //nolint:errcheck // Look up env name for the event payload. var env models.Environment c.db.ID(cfg.EnvID).Get(&env) //nolint:errcheck c.bus.Publish(events.SubjectEnvironmentDriftDetected, events.DriftEvent{ //nolint:errcheck EnvID: cfg.EnvID, EnvName: env.Name, RepoID: cfg.RepoID, DesiredSHA: desiredSHA, ActualSHA: cfg.ActualSHA, At: now, }) if cfg.AutoSync { c.TriggerSync(cfg, desiredSHA) } } // periodicCheck runs on a ticker and re-evaluates drift for every GitOpsConfig // whose SyncInterval has elapsed. func (c *Controller) periodicCheck() { now := time.Now().UTC() var cfgs []models.GitOpsConfig if err := c.db.Where("sync_interval > 0").Find(&cfgs); err != nil { return } for _, cfg := range cfgs { elapsed := now.Unix() - lastChecked(cfg).Unix() if int(elapsed) < cfg.SyncInterval { continue } var repo models.Repository if found, _ := c.db.ID(cfg.RepoID).Get(&repo); !found { continue } desiredSHA, drifted, err := CheckDrift(repo.DiskPath, cfg.Branch, cfg.ActualSHA) if err != nil { log.Printf("gitops: periodic check env %d: %v", cfg.EnvID, err) now2 := time.Now().UTC() cfg.LastCheckedAt = &now2 c.db.ID(cfg.ID).Cols("last_checked_at").Update(&cfg) //nolint:errcheck continue } if drifted { c.evaluateDrift(cfg, desiredSHA) } else { now2 := time.Now().UTC() cfg.LastCheckedAt = &now2 c.db.ID(cfg.ID).Cols("last_checked_at").Update(&cfg) //nolint:errcheck } } } // markSynced resolves any open drift events for envID and updates the config. func markSynced(db *xorm.Engine, envID int64, sha string) { now := time.Now().UTC() db.Where("env_id = ? AND resolved_at IS NULL", envID). Cols("sync_status", "resolved_at"). Update(&models.GitOpsDriftEvent{SyncStatus: "synced", ResolvedAt: &now}) //nolint:errcheck db.Where("env_id = ?", envID). Cols("sync_status", "actual_sha", "last_checked_at"). Update(&models.GitOpsConfig{SyncStatus: "synced", ActualSHA: sha, LastCheckedAt: &now}) //nolint:errcheck } func lastChecked(cfg models.GitOpsConfig) time.Time { if cfg.LastCheckedAt != nil { return *cfg.LastCheckedAt } return cfg.CreatedAt } func sha7(s string) string { if len(s) >= 7 { return s[:7] } if s == "" { return "(none)" } return s }