implemented gitops controller + drift detection
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user