f1bcb8cd3a
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.
141 lines
3.9 KiB
Go
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
|
|
}
|