Files
repospawner/internal/server/reconcile.go
T
unkin-agent f1bcb8cd3a
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add the initial repospawner service
repospawner turns JSON new-repo requests into terraform-git pull requests
via kubernetes Jobs, follows those PRs to merge and optionally activates
the repository in Woodpecker.
2026-08-30 14:33:31 +10:00

141 lines
3.9 KiB
Go

package server
import (
"context"
"time"
corev1 "k8s.io/api/core/v1"
"git.unkin.net/unkin/repospawner/internal/jobs"
"git.unkin.net/unkin/repospawner/internal/store"
)
// reconcileInterval is how often the server folds Job state into the store. The
// UI polls on the same cadence, so a change surfaces within two ticks.
const reconcileInterval = 10 * time.Second
// Run reconciles until ctx is cancelled, starting with an immediate pass so a
// restarted server rebuilds its state before serving its first request.
func (s *Server) Run(ctx context.Context) {
if err := s.Reconcile(ctx); err != nil {
s.log.Error("initial reconcile failed", "err", err)
}
t := time.NewTicker(reconcileInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := s.Reconcile(ctx); err != nil {
s.log.Error("reconcile failed", "err", err)
}
}
}
}
// Reconcile reads every repospawner Job, advances each request's state and
// creates whatever Job comes next. It is also the startup recovery path: a
// request the store has never seen is rebuilt from its Job annotations.
func (s *Server) Reconcile(ctx context.Context) error {
jobList, err := s.cluster.ListJobs(ctx)
if err != nil {
return err
}
podList, err := s.cluster.ListPods(ctx)
if err != nil {
return err
}
results := terminationMessages(podList)
byRequest := map[string]map[jobs.Type]jobs.View{}
for _, j := range jobList {
id := j.Labels[jobs.LabelRequest]
t := jobs.Type(j.Labels[jobs.LabelType])
if id == "" || t == "" {
continue
}
v := jobs.ViewOf(j, results[resultKey{id, t}])
if byRequest[id] == nil {
byRequest[id] = map[jobs.Type]jobs.View{}
}
byRequest[id][t] = v
}
for id, views := range byRequest {
current, ok := s.store.Get(id)
if !ok {
current = rebuild(views)
}
next, action := jobs.Advance(current, views)
s.store.Put(next)
if err := s.act(ctx, next, action); err != nil {
s.log.Error("create follow-up job", "request", id, "action", string(action), "err", err)
}
}
return nil
}
// rebuild reconstructs a request the store lost, preferring the newest job's
// annotations because those carry the pull request coordinates.
func rebuild(views map[jobs.Type]jobs.View) store.Request {
for _, t := range []jobs.Type{jobs.TypeWoodpecker, jobs.TypeWatch, jobs.TypePR} {
if v, ok := views[t]; ok {
return jobs.RequestFrom(v)
}
}
return store.Request{}
}
func (s *Server) act(ctx context.Context, r store.Request, action jobs.Action) error {
switch action {
case jobs.ActionCreateWatch:
s.log.Info("following terraform-git pull request", "request", r.ID, "pr", r.PRNumber)
return s.cluster.CreateJob(ctx, jobs.Watch(s.cfg, r))
case jobs.ActionCreateWoodpecker:
if !s.woodpeckerAvailable() {
s.log.Warn("woodpecker enablement requested but no token is mounted", "request", r.ID)
return nil
}
s.log.Info("enabling repository in woodpecker", "request", r.ID, "name", r.Name)
return s.cluster.CreateJob(ctx, jobs.Woodpecker(s.cfg, r))
case jobs.ActionNone:
return nil
default:
return nil
}
}
type resultKey struct {
request string
jobType jobs.Type
}
// terminationMessages collects each job pod's termination message, preferring a
// terminated container over one that is merely waiting to restart.
func terminationMessages(pods []corev1.Pod) map[resultKey][]byte {
out := map[resultKey][]byte{}
for _, p := range pods {
id := p.Labels[jobs.LabelRequest]
t := jobs.Type(p.Labels[jobs.LabelType])
if id == "" || t == "" {
continue
}
for _, cs := range p.Status.ContainerStatuses {
term := cs.State.Terminated
if term == nil && cs.LastTerminationState.Terminated != nil {
term = cs.LastTerminationState.Terminated
}
if term == nil || term.Message == "" {
continue
}
key := resultKey{id, t}
// A retried pod leaves several messages; the successful one wins.
if _, seen := out[key]; !seen || term.ExitCode == 0 {
out[key] = []byte(term.Message)
}
}
}
return out
}