package handlers import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "log" "armature/models" "armature/store" "armature/triggers" ) // manifestTrigger is the manifest.json trigger declaration shape. type manifestTrigger struct { Type string `json:"type"` // "event" or "webhook" Pattern string `json:"pattern"` // event pattern (for type=event) Slug string `json:"slug"` // webhook slug (for type=webhook) EntryPoint string `json:"entry_point"` // Starlark function name } // SyncManifestTriggers parses the "triggers" array from a package manifest // and syncs them with the trigger store. Removes triggers no longer in the // manifest (declarative sync). For webhooks, generates an HMAC secret on // first create. // // Called from admin install and package install. func SyncManifestTriggers(ctx context.Context, stores store.Stores, engine *triggers.Engine, pkgID string, manifest map[string]any) { if stores.Triggers == nil { return } // Parse triggers from manifest triggersRaw, ok := manifest["triggers"] if !ok { return } data, err := json.Marshal(triggersRaw) if err != nil { return } var declared []manifestTrigger if err := json.Unmarshal(data, &declared); err != nil { log.Printf(" ⚠️ trigger-sync[%s]: invalid triggers in manifest: %v", pkgID, err) return } if len(declared) == 0 { return } // Get existing triggers for this package existing, err := stores.Triggers.ListByPackage(ctx, pkgID) if err != nil { log.Printf(" ⚠️ trigger-sync[%s]: failed to list existing: %v", pkgID, err) return } // Build lookup maps existingByKey := make(map[string]*models.Trigger, len(existing)) for i := range existing { key := triggerKey(&existing[i]) existingByKey[key] = &existing[i] } declaredKeys := make(map[string]bool, len(declared)) created, updated := 0, 0 for _, dt := range declared { if dt.Type != models.TriggerTypeEvent && dt.Type != models.TriggerTypeWebhook { log.Printf(" ⚠️ trigger-sync[%s]: unknown trigger type %q, skipping", pkgID, dt.Type) continue } if dt.EntryPoint == "" { continue } key := dt.Type + ":" + triggerIdentifier(dt) declaredKeys[key] = true if ex, ok := existingByKey[key]; ok { // Update if entry_point changed if ex.EntryPoint != dt.EntryPoint { ex.EntryPoint = dt.EntryPoint if err := stores.Triggers.Update(ctx, ex); err != nil { log.Printf(" ⚠️ trigger-sync[%s]: update failed: %v", pkgID, err) } else { updated++ } } continue } // Create new trigger t := &models.Trigger{ PackageID: pkgID, Type: dt.Type, Enabled: true, EntryPoint: dt.EntryPoint, Config: json.RawMessage("{}"), } switch dt.Type { case models.TriggerTypeEvent: t.EventPattern = dt.Pattern case models.TriggerTypeWebhook: t.Slug = dt.Slug t.Secret = generateSecret() } if err := stores.Triggers.Create(ctx, t); err != nil { log.Printf(" ⚠️ trigger-sync[%s]: create failed: %v", pkgID, err) continue } created++ // Register with engine if engine != nil { // Re-fetch to get the generated ID switch dt.Type { case models.TriggerTypeWebhook: if wh, _ := stores.Triggers.GetWebhook(ctx, pkgID, dt.Slug); wh != nil { engine.RegisterTrigger(wh) } case models.TriggerTypeEvent: // Need to fetch by package to find the new one triggers, _ := stores.Triggers.ListByPackage(ctx, pkgID) for i := range triggers { if triggers[i].EventPattern == dt.Pattern { engine.RegisterTrigger(&triggers[i]) break } } } } } // Remove triggers no longer in manifest removed := 0 for key, ex := range existingByKey { if !declaredKeys[key] { if engine != nil { engine.UnregisterTrigger(ex.ID) } if err := stores.Triggers.Delete(ctx, ex.ID); err != nil { log.Printf(" ⚠️ trigger-sync[%s]: delete failed: %v", pkgID, err) } else { removed++ } } } if created+updated+removed > 0 { log.Printf(" 🔔 trigger-sync[%s]: %d created, %d updated, %d removed", pkgID, created, updated, removed) } } // triggerKey returns a unique key for a trigger within a package. func triggerKey(t *models.Trigger) string { return t.Type + ":" + triggerIdentifierFromModel(t) } func triggerIdentifier(dt manifestTrigger) string { switch dt.Type { case models.TriggerTypeEvent: return dt.Pattern case models.TriggerTypeWebhook: return dt.Slug } return "" } func triggerIdentifierFromModel(t *models.Trigger) string { switch t.Type { case models.TriggerTypeEvent: return t.EventPattern case models.TriggerTypeWebhook: return t.Slug } return "" } // generateSecret creates a random 32-byte hex-encoded secret for webhook HMAC. func generateSecret() string { b := make([]byte, 32) _, _ = rand.Read(b) return hex.EncodeToString(b) }