implemented gitops controller + drift detection
This commit is contained in:
@@ -340,26 +340,15 @@ func (h *EnvironmentHandler) resolveEnv(w http.ResponseWriter, r *http.Request)
|
||||
return &env, true
|
||||
}
|
||||
|
||||
type deployEventPayload struct {
|
||||
DeploymentID int64 `json:"deploymentId"`
|
||||
EnvID int64 `json:"envId"`
|
||||
EnvName string `json:"envName"`
|
||||
RepoID int64 `json:"repoId"`
|
||||
SHA string `json:"sha"`
|
||||
Ref string `json:"ref"`
|
||||
Status models.DeployStatus `json:"status"`
|
||||
TriggeredBy string `json:"triggeredBy"`
|
||||
}
|
||||
|
||||
func (h *EnvironmentHandler) publishDeployEvent(subject string, env *models.Environment, d *models.Deployment) {
|
||||
h.bus.Publish(subject, deployEventPayload{ //nolint:errcheck
|
||||
h.bus.Publish(subject, events.DeploymentEvent{ //nolint:errcheck
|
||||
DeploymentID: d.ID,
|
||||
EnvID: env.ID,
|
||||
EnvName: env.Name,
|
||||
RepoID: d.RepoID,
|
||||
SHA: d.SHA,
|
||||
Ref: d.Ref,
|
||||
Status: d.Status,
|
||||
Status: string(d.Status),
|
||||
TriggeredBy: d.TriggeredBy,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/forgeo/forgebucket/internal/events"
|
||||
"github.com/forgeo/forgebucket/internal/models"
|
||||
)
|
||||
|
||||
type GitOpsHandler struct {
|
||||
db *xorm.Engine
|
||||
bus events.EventBus
|
||||
}
|
||||
|
||||
func NewGitOpsHandler(db *xorm.Engine, bus events.EventBus) *GitOpsHandler {
|
||||
return &GitOpsHandler{db: db, bus: bus}
|
||||
}
|
||||
|
||||
// GetConfig returns the GitOpsConfig for an environment, or 404 if not configured.
|
||||
func (h *GitOpsHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
env, ok := h.resolveGitOpsEnv(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var cfg models.GitOpsConfig
|
||||
if found, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg); !found {
|
||||
jsonError(w, "gitops not configured for this environment", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
jsonOK(w, cfg)
|
||||
}
|
||||
|
||||
// UpsertConfig creates or replaces the GitOpsConfig for an environment.
|
||||
func (h *GitOpsHandler) UpsertConfig(w http.ResponseWriter, r *http.Request) {
|
||||
env, ok := h.resolveGitOpsEnv(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Branch string `json:"branch"`
|
||||
AutoSync bool `json:"autoSync"`
|
||||
SyncInterval int `json:"syncInterval"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Branch == "" {
|
||||
jsonError(w, "branch is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var cfg models.GitOpsConfig
|
||||
exists, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg)
|
||||
|
||||
cfg.EnvID = env.ID
|
||||
cfg.RepoID = env.RepoID
|
||||
cfg.Branch = body.Branch
|
||||
cfg.AutoSync = body.AutoSync
|
||||
cfg.SyncInterval = body.SyncInterval
|
||||
if cfg.SyncStatus == "" {
|
||||
cfg.SyncStatus = "unknown"
|
||||
}
|
||||
|
||||
var err error
|
||||
if exists {
|
||||
_, err = h.db.ID(cfg.ID).Cols("branch", "auto_sync", "sync_interval").Update(&cfg)
|
||||
} else {
|
||||
_, err = h.db.Insert(&cfg)
|
||||
}
|
||||
if err != nil {
|
||||
jsonError(w, "could not save gitops config", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, cfg)
|
||||
}
|
||||
|
||||
// DeleteConfig removes the GitOpsConfig for an environment without deleting deployments.
|
||||
func (h *GitOpsHandler) DeleteConfig(w http.ResponseWriter, r *http.Request) {
|
||||
env, ok := h.resolveGitOpsEnv(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := h.db.Where("env_id = ?", env.ID).Delete(&models.GitOpsConfig{}); err != nil {
|
||||
jsonError(w, "could not delete gitops config", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// TriggerSync manually initiates a reconciliation for the environment.
|
||||
func (h *GitOpsHandler) TriggerSync(w http.ResponseWriter, r *http.Request) {
|
||||
env, ok := h.resolveGitOpsEnv(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var cfg models.GitOpsConfig
|
||||
if found, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg); !found {
|
||||
jsonError(w, "gitops not configured for this environment", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if cfg.DesiredSHA == "" {
|
||||
jsonError(w, "no desired SHA known yet — push to the configured branch first", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if cfg.SyncStatus == "syncing" {
|
||||
jsonError(w, "a sync is already in progress", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
deploy := &models.Deployment{
|
||||
EnvID: env.ID,
|
||||
RepoID: env.RepoID,
|
||||
SHA: cfg.DesiredSHA,
|
||||
Ref: "refs/heads/" + cfg.Branch,
|
||||
Status: models.DeployStatusPending,
|
||||
TriggeredBy: "gitops-manual",
|
||||
Description: "Manual GitOps sync",
|
||||
StartedAt: &now,
|
||||
}
|
||||
if _, err := h.db.Insert(deploy); err != nil {
|
||||
jsonError(w, "could not create deployment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cfg.SyncStatus = "syncing"
|
||||
h.db.ID(cfg.ID).Cols("sync_status").Update(&cfg) //nolint:errcheck
|
||||
|
||||
h.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,
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, deploy)
|
||||
}
|
||||
|
||||
// GetDriftStatus returns the current sync status and SHA comparison for an environment.
|
||||
func (h *GitOpsHandler) GetDriftStatus(w http.ResponseWriter, r *http.Request) {
|
||||
env, ok := h.resolveGitOpsEnv(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var cfg models.GitOpsConfig
|
||||
if found, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg); !found {
|
||||
jsonError(w, "gitops not configured for this environment", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
type driftStatus struct {
|
||||
SyncStatus string `json:"syncStatus"`
|
||||
DesiredSHA string `json:"desiredSha"`
|
||||
ActualSHA string `json:"actualSha"`
|
||||
Branch string `json:"branch"`
|
||||
IsDrifted bool `json:"isDrifted"`
|
||||
}
|
||||
jsonOK(w, driftStatus{
|
||||
SyncStatus: cfg.SyncStatus,
|
||||
DesiredSHA: cfg.DesiredSHA,
|
||||
ActualSHA: cfg.ActualSHA,
|
||||
Branch: cfg.Branch,
|
||||
IsDrifted: cfg.DesiredSHA != cfg.ActualSHA && cfg.DesiredSHA != "",
|
||||
})
|
||||
}
|
||||
|
||||
// ListDriftEvents returns the drift history for an environment, newest first.
|
||||
func (h *GitOpsHandler) ListDriftEvents(w http.ResponseWriter, r *http.Request) {
|
||||
env, ok := h.resolveGitOpsEnv(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 200 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
var drifts []models.GitOpsDriftEvent
|
||||
if err := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(limit).Find(&drifts); err != nil {
|
||||
jsonError(w, "could not list drift events", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if drifts == nil {
|
||||
drifts = []models.GitOpsDriftEvent{}
|
||||
}
|
||||
jsonOK(w, drifts)
|
||||
}
|
||||
|
||||
// AcknowledgeDrift marks a drift event as acknowledged without triggering a sync.
|
||||
func (h *GitOpsHandler) AcknowledgeDrift(w http.ResponseWriter, r *http.Request) {
|
||||
env, ok := h.resolveGitOpsEnv(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
driftID, err := strconv.ParseInt(chi.URLParam(r, "driftID"), 10, 64)
|
||||
if err != nil {
|
||||
jsonError(w, "invalid drift event ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var drift models.GitOpsDriftEvent
|
||||
if found, _ := h.db.Where("id = ? AND env_id = ?", driftID, env.ID).Get(&drift); !found {
|
||||
jsonError(w, "drift event not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if drift.ResolvedAt != nil {
|
||||
jsonError(w, "drift event is already resolved", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
drift.SyncStatus = "acknowledged"
|
||||
drift.ResolvedAt = &now
|
||||
if _, err := h.db.ID(drift.ID).Cols("sync_status", "resolved_at").Update(&drift); err != nil {
|
||||
jsonError(w, "could not acknowledge drift", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, drift)
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *GitOpsHandler) resolveGitOpsEnv(w http.ResponseWriter, r *http.Request) (*models.Environment, bool) {
|
||||
repoID, ok := resolveRepoID(h.db, w, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
envName := chi.URLParam(r, "envName")
|
||||
var env models.Environment
|
||||
if found, _ := h.db.Where("repo_id = ? AND name = ?", repoID, envName).Get(&env); !found {
|
||||
jsonError(w, "environment not found", http.StatusNotFound)
|
||||
return nil, false
|
||||
}
|
||||
return &env, true
|
||||
}
|
||||
@@ -58,6 +58,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
||||
auditH := handlers.NewAuditHandler(engine)
|
||||
artifactH := handlers.NewArtifactHandler(engine, artifactRoot)
|
||||
runnerH := handlers.NewRunnerHandler(engine)
|
||||
gitopsH := handlers.NewGitOpsHandler(engine, bus)
|
||||
envH := handlers.NewEnvironmentHandler(engine, bus)
|
||||
timelineH := handlers.NewTimelineHandler(engine, cfg.RepoRoot)
|
||||
workspaceH := handlers.NewWorkspaceHandler(engine, cfg)
|
||||
@@ -254,6 +255,15 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
||||
r.Get("/secrets", secretH.ListEnvSecrets)
|
||||
r.With(csrf).Post("/secrets", secretH.UpsertEnvSecret)
|
||||
r.With(csrf).Delete("/secrets/{name}", secretH.DeleteEnvSecret)
|
||||
r.Route("/gitops", func(r chi.Router) {
|
||||
r.Get("/", gitopsH.GetConfig)
|
||||
r.With(csrf).Put("/", gitopsH.UpsertConfig)
|
||||
r.With(csrf).Delete("/", gitopsH.DeleteConfig)
|
||||
r.With(csrf).Post("/sync", gitopsH.TriggerSync)
|
||||
r.Get("/drift", gitopsH.GetDriftStatus)
|
||||
r.Get("/drift/history", gitopsH.ListDriftEvents)
|
||||
r.With(csrf).Post("/drift/{driftID}/acknowledge", gitopsH.AcknowledgeDrift)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,9 @@ type Config struct {
|
||||
// Event bus
|
||||
NATSUrl string
|
||||
|
||||
// GitOps
|
||||
GitOpsReconcileInterval int // seconds between periodic drift checks; 0 disables
|
||||
|
||||
// Federation
|
||||
InstanceURL string
|
||||
InstanceName string
|
||||
@@ -46,7 +49,8 @@ func Load() (*Config, error) {
|
||||
ArtifactRoot: getEnv("ARTIFACT_ROOT", filepath.Join(filepath.Dir(repoRoot), "artifacts")),
|
||||
Debug: getEnvBool("DEBUG", false),
|
||||
|
||||
NATSUrl: getEnv("NATS_URL", ""),
|
||||
NATSUrl: getEnv("NATS_URL", ""),
|
||||
GitOpsReconcileInterval: getEnvInt("GITOPS_RECONCILE_INTERVAL", 300),
|
||||
InstanceURL: getEnv("INSTANCE_URL", ""),
|
||||
InstanceName: getEnv("INSTANCE_NAME", "ForgeBucket"),
|
||||
}
|
||||
@@ -91,6 +95,18 @@ func getEnv(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -79,6 +79,29 @@ type LogChunkEvent struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// DeploymentEvent is published on deployment lifecycle transitions.
|
||||
// It matches the payload shape used by EnvironmentHandler.publishDeployEvent.
|
||||
type DeploymentEvent struct {
|
||||
DeploymentID int64 `json:"deploymentId"`
|
||||
EnvID int64 `json:"envId"`
|
||||
EnvName string `json:"envName"`
|
||||
RepoID int64 `json:"repoId"`
|
||||
SHA string `json:"sha"`
|
||||
Ref string `json:"ref"`
|
||||
Status string `json:"status"`
|
||||
TriggeredBy string `json:"triggeredBy"`
|
||||
}
|
||||
|
||||
// DriftEvent is published when an environment's actual state diverges from desired.
|
||||
type DriftEvent struct {
|
||||
EnvID int64 `json:"envId"`
|
||||
EnvName string `json:"envName"`
|
||||
RepoID int64 `json:"repoId"`
|
||||
DesiredSHA string `json:"desiredSha"`
|
||||
ActualSHA string `json:"actualSha"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// WSEnvelope wraps any event for delivery over the WebSocket connection.
|
||||
type WSEnvelope struct {
|
||||
Subject string `json:"subject"`
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// GitOpsConfig links an Environment to a branch that serves as its desired state.
|
||||
// When the HEAD SHA of Branch diverges from ActualSHA, the environment is "drifted".
|
||||
type GitOpsConfig struct {
|
||||
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
||||
EnvID int64 `xorm:"'env_id' unique notnull index" json:"envId"` // one config per env
|
||||
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
|
||||
Branch string `xorm:"'branch' varchar(255) notnull" json:"branch"` // source-of-truth branch
|
||||
AutoSync bool `xorm:"'auto_sync' default false" json:"autoSync"` // create deployment on drift
|
||||
SyncInterval int `xorm:"'sync_interval' default 0" json:"syncInterval"` // seconds; 0 = push-only
|
||||
SyncStatus string `xorm:"'sync_status' varchar(20) default 'unknown'" json:"syncStatus"` // unknown/synced/drifted/syncing
|
||||
DesiredSHA string `xorm:"'desired_sha' varchar(40)" json:"desiredSha"` // last known branch HEAD
|
||||
ActualSHA string `xorm:"'actual_sha' varchar(40)" json:"actualSha"` // SHA of last successful deploy
|
||||
LastCheckedAt *time.Time `xorm:"'last_checked_at'" json:"lastCheckedAt"`
|
||||
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
|
||||
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// GitOpsDriftEvent is an append-only record of each drift detection and its resolution.
|
||||
type GitOpsDriftEvent struct {
|
||||
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
||||
EnvID int64 `xorm:"'env_id' notnull index" json:"envId"`
|
||||
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
|
||||
DesiredSHA string `xorm:"'desired_sha' varchar(40)" json:"desiredSha"` // SHA that should be deployed
|
||||
ActualSHA string `xorm:"'actual_sha' varchar(40)" json:"actualSha"` // SHA actually deployed (empty = never)
|
||||
SyncStatus string `xorm:"'sync_status' varchar(20)" json:"syncStatus"` // drifted/synced/acknowledged
|
||||
DetectedAt time.Time `xorm:"'detected_at' notnull index" json:"detectedAt"`
|
||||
ResolvedAt *time.Time `xorm:"'resolved_at'" json:"resolvedAt"`
|
||||
}
|
||||
@@ -46,5 +46,8 @@ func Run(engine *xorm.Engine) error {
|
||||
if err := Run011(engine); err != nil {
|
||||
return err
|
||||
}
|
||||
return Run012(engine)
|
||||
if err := Run012(engine); err != nil {
|
||||
return err
|
||||
}
|
||||
return Run013(engine)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/forgeo/forgebucket/internal/models"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func Run013(engine *xorm.Engine) error {
|
||||
return engine.Sync2(
|
||||
&models.GitOpsConfig{},
|
||||
&models.GitOpsDriftEvent{},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user