Skip to content

gitops-responder

When a detection produces an EventResponse whose action.type is GitOpsChange, ugallu-gitops-responder translates it into a real PR or MR against the GitOps repo. The responder is the only ugallu component that talks to a Git provider - every other operator stops at “emit a CR”.

EventResponse CRs cluster-wide where:

  • spec.action.type == "GitOpsChange"
  • spec.responder.name == "ugallu-gitops-responder" (so two responders can coexist for different routing)

The router matches the ER against routing rules from GitOpsResponderConfig (loaded once at boot - no live reload yet) and dispatches to a provider client.

ProviderAuth
GitHubGitHub App (preferred - short-lived installation tokens, fine-grained permissions) or fine-grained PAT.
GitLabProject access token via Secret reference.
noopRecords the would-be PR in the EventResponse status without calling out - used in dev / test environments and during stack bring-up.

The current build ships the noop provider end-to-end; GitHub / GitLab clients are wired but their integration is the focus of an upcoming sprint.

A re-emit of the same EventResponse (same UID) is a no-op once the PR has been created - the PR URL lives on status.providerRef and the responder short-circuits when it sees one. A failed attempt retries up to --max-attempts (default honours the CR’s spec.maxAttempts).

apiVersion: security.ugallu.io/v1alpha1
kind: GitOpsResponderConfig
metadata: { name: default, namespace: ugallu-system }
spec:
routes:
- match:
actionRef: revoke-clusterrolebinding
provider:
kind: github-app
repository: ninsun-labs/ugallu-gitops
appCredentialsRef:
name: gitops-responder-github-app
installationID: 12345
baseBranch: main
prTemplate: |
## Auto-generated by ugallu-gitops-responder
Triggered by `{{ .Source.SE.Type }}` on `{{ .Source.SE.Subject.Name }}`.

EventResponse (consumed, not owned by this operator): Pending -> Running -> Succeeded | Failed | Cancelled.

on each EventResponse event:
er := Get(req)
if er.Spec.Action.Type != "GitOpsChange": return
if er.Spec.Responder.Name != "ugallu-gitops-responder": return
if er.Status.Phase in {Succeeded, Failed, Cancelled}: return
if er.Status.ProviderRef != "": # already opened upstream
return
route := router.match(er) # boot-time routing rules
provider := providers[route.Provider]
attempt = er.Status.Attempts + 1
resp, err := provider.Apply(ctx, er, route.Repo)
patch Status.Attempts = attempt, LastAttemptAt = now
if err && attempt < maxAttempts:
patch Status.Phase = Running
RequeueAfter: backoff(attempt)
return
patch Status.Phase = (err ? Failed : Succeeded), ProviderRef = resp.URL

Pod restart mid-Apply: new pod re-Gets the ER (still Pending/Running), retries provider.Apply(). Git providers are expected to be idempotent (PRs are upserted by branch + title, not created fresh). If the previous attempt did open a PR but the responder crashed before writing ProviderRef, the next attempt detects the existing PR and writes the URL to the status.

Pod killed after provider.Apply() succeeded but before Phase=Succeeded was written: the new pod re-Gets the ER, calls provider.Apply() again. The provider sees the open PR for this ER’s logical key and returns its URL; the responder writes ProviderRef and flips Phase=Succeeded.

  • Boot-time config load. GitOpsResponderConfig is read once at startup; live reload is on the roadmap. Restart the Deployment to pick up routing changes.
  • Two responders. Multiple gitops-responders can coexist by matching different EventResponse.Spec.Responder.Name values.
  • noop provider. Useful in dev / test environments and during stack bring-up; records the would-be PR in the ER status without calling out.
  • Recreate strategy. The Deployment uses strategy: Recreate because the in-memory router snapshot doesn’t share state across replicas.
# ClusterRole
rules:
- apiGroups: [security.ugallu.io]
resources: [eventresponses]
verbs: [get, list, watch, patch]
- apiGroups: [security.ugallu.io]
resources: [eventresponses/status]
verbs: [update, patch]
- apiGroups: [security.ugallu.io]
resources: [securityevents, gitopsresponderconfigs]
verbs: [get, list, watch]
- apiGroups: [""]
resources: [secrets]
verbs: [get, list, watch] # provider credentials
- apiGroups: [""]
resources: [events]
verbs: [create, patch]
# Namespaced Role
- apiGroups: [coordination.k8s.io]
resources: [leases]
verbs: [get, list, watch, create, update, patch, delete]
  • Watched: EventResponse (does not own).
  • Owned: none - GitOpsResponderConfig is read-only at boot.

--cluster-id, --cluster-name, --config-namespace (default ugallu-system), --max-attempts (0 = honour CR).

Deployment (1 replica, Recreate strategy because the in-memory router snapshot doesn’t share state) in ugallu-system, leader election on, priorityClassName=system-cluster-critical. Reads provider Secrets only from the configured namespace.

ugallu_gitops_responder_dispatches_total{provider,outcome}, ugallu_gitops_responder_attempts_total{outcome}, ugallu_gitops_responder_routing_misses_total.