implemented gitops controller + drift detection

This commit is contained in:
2026-05-12 19:51:59 +02:00
parent 35afa8d8f1
commit c7df53708c
17 changed files with 1064 additions and 261 deletions
+9
View File
@@ -283,6 +283,15 @@ func RepoSize(repoPath string) int64 {
return total
}
// RevParse resolves a ref (branch name, tag, or SHA) to its full commit SHA.
func RevParse(repoPath, ref string) (string, error) {
out, err := run(repoPath, "rev-parse", "--verify", ref)
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// SetDefaultBranch updates HEAD to point at the given branch name.
func SetDefaultBranch(repoPath, branch string) error {
_, err := run(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+branch)
+95
View File
@@ -0,0 +1,95 @@
package gitops
import (
"context"
"encoding/json"
"log"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/config"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
// Controller is the GitOps reconciliation engine. It subscribes to NATS events
// and drives drift detection + auto-sync for every configured environment.
type Controller struct {
db *xorm.Engine
bus events.EventBus
cfg *config.Config
}
func NewController(db *xorm.Engine, bus events.EventBus, cfg *config.Config) *Controller {
return &Controller{db: db, bus: bus, cfg: cfg}
}
// Start subscribes to relevant events and blocks until ctx is cancelled.
func (c *Controller) Start(ctx context.Context) {
c.recoverSyncingState()
unsub1, err := c.bus.Subscribe(events.SubjectPushReceived, func(_ string, data []byte) {
var evt events.PushEvent
if err := json.Unmarshal(data, &evt); err != nil {
log.Printf("gitops: bad push.received payload: %v", err)
return
}
go c.handlePush(evt)
})
if err != nil {
log.Printf("gitops: subscribe push.received: %v", err)
} else {
defer unsub1()
}
unsub2, err := c.bus.Subscribe(events.SubjectDeploymentSucceeded, func(_ string, data []byte) {
go c.handleDeploymentSucceeded(data)
})
if err != nil {
log.Printf("gitops: subscribe deployment.succeeded: %v", err)
} else {
defer unsub2()
}
unsub3, err := c.bus.Subscribe(events.SubjectDeploymentFailed, func(_ string, data []byte) {
go c.handleDeploymentFailed(data)
})
if err != nil {
log.Printf("gitops: subscribe deployment.failed: %v", err)
} else {
defer unsub3()
}
if c.cfg.GitOpsReconcileInterval > 0 {
go c.runTicker(ctx)
}
log.Printf("gitops: controller started (reconcile interval: %ds)", c.cfg.GitOpsReconcileInterval)
<-ctx.Done()
}
func (c *Controller) runTicker(ctx context.Context) {
interval := time.Duration(c.cfg.GitOpsReconcileInterval) * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.periodicCheck()
case <-ctx.Done():
return
}
}
}
// recoverSyncingState marks any configs left in "syncing" as "drifted" on startup
// (they were in-flight when the server last stopped).
func (c *Controller) recoverSyncingState() {
affected, _ := c.db.Where("sync_status = 'syncing'").
Cols("sync_status").
Update(&models.GitOpsConfig{SyncStatus: "drifted"})
if affected > 0 {
log.Printf("gitops: recovered %d stale syncing configs → drifted", affected)
}
}
+168
View File
@@ -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
}
+97
View File
@@ -0,0 +1,97 @@
package gitops
import (
"encoding/json"
"log"
"time"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
// TriggerSync creates a Deployment record in "pending" state and fires
// deployment.started — the same path as a manually-triggered deployment.
// GitOps is just the trigger; actual execution is handled externally (or via CI).
func (c *Controller) TriggerSync(cfg models.GitOpsConfig, desiredSHA string) {
var env models.Environment
if found, _ := c.db.ID(cfg.EnvID).Get(&env); !found {
log.Printf("gitops: sync env %d not found", cfg.EnvID)
return
}
now := time.Now().UTC()
deploy := &models.Deployment{
EnvID: cfg.EnvID,
RepoID: cfg.RepoID,
SHA: desiredSHA,
Ref: "refs/heads/" + cfg.Branch,
Status: models.DeployStatusPending,
TriggeredBy: "gitops",
Description: "GitOps auto-sync",
StartedAt: &now,
}
if _, err := c.db.Insert(deploy); err != nil {
log.Printf("gitops: create deployment: %v", err)
return
}
cfg.SyncStatus = "syncing"
c.db.ID(cfg.ID).Cols("sync_status").Update(&cfg) //nolint:errcheck
c.bus.Publish(events.SubjectDeploymentStarted, events.DeploymentEvent{ //nolint:errcheck
DeploymentID: deploy.ID,
EnvID: env.ID,
EnvName: env.Name,
RepoID: deploy.RepoID,
SHA: deploy.SHA,
Ref: deploy.Ref,
Status: string(deploy.Status),
TriggeredBy: deploy.TriggeredBy,
})
log.Printf("gitops: triggered sync deploy %d for env %d (%s)", deploy.ID, cfg.EnvID, desiredSHA[:7])
}
// handleDeploymentSucceeded is called when any deployment.succeeded event fires.
// If the deployment was GitOps-triggered, it marks the config as synced.
func (c *Controller) handleDeploymentSucceeded(data []byte) {
var evt events.DeploymentEvent
if err := json.Unmarshal(data, &evt); err != nil {
return
}
// Only act on deployments triggered by gitops.
if evt.TriggeredBy != "gitops" {
// Still update ActualSHA and resolve drift if this env has a GitOps config —
// manual deployments also advance the state.
var cfg models.GitOpsConfig
if found, _ := c.db.Where("env_id = ?", evt.EnvID).Get(&cfg); found {
markSynced(c.db, evt.EnvID, evt.SHA)
log.Printf("gitops: env %d synced via manual deploy (%s)", evt.EnvID, sha7(evt.SHA))
}
return
}
markSynced(c.db, evt.EnvID, evt.SHA)
log.Printf("gitops: env %d synced (%s)", evt.EnvID, sha7(evt.SHA))
}
// handleDeploymentFailed is called when deployment.failed fires.
// If the deployment was GitOps-triggered, it reverts SyncStatus back to drifted.
func (c *Controller) handleDeploymentFailed(data []byte) {
var evt events.DeploymentEvent
if err := json.Unmarshal(data, &evt); err != nil {
return
}
if evt.TriggeredBy != "gitops" {
return
}
var cfg models.GitOpsConfig
if found, _ := c.db.Where("env_id = ?", evt.EnvID).Get(&cfg); !found {
return
}
cfg.SyncStatus = "drifted"
c.db.ID(cfg.ID).Cols("sync_status").Update(&cfg) //nolint:errcheck
log.Printf("gitops: env %d sync failed — reverting to drifted", evt.EnvID)
}