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.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
// Package store holds the in-flight repo requests. State lives in memory and is
|
||||
// rebuilt from the request Jobs on startup, so the deployment must stay at one
|
||||
// replica with a Recreate strategy.
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/repospawner/internal/repospec"
|
||||
)
|
||||
|
||||
// State is where a request has reached.
|
||||
type State string
|
||||
|
||||
const (
|
||||
// StateOpeningPR means the PR job is running.
|
||||
StateOpeningPR State = "opening-pr"
|
||||
// StatePROpen means the PR exists and the watch job is following it.
|
||||
StatePROpen State = "pr-open"
|
||||
// StateMerged means the terraform-git PR merged.
|
||||
StateMerged State = "merged"
|
||||
// StateEnablingCI means the Woodpecker enablement job is running.
|
||||
StateEnablingCI State = "enabling-ci"
|
||||
// StateReady means everything the request asked for is done.
|
||||
StateReady State = "ready"
|
||||
// StateClosed means the PR was closed without merging.
|
||||
StateClosed State = "closed"
|
||||
// StateFailed means a job failed; Error carries why.
|
||||
StateFailed State = "failed"
|
||||
)
|
||||
|
||||
// Terminal reports whether a state will not change again.
|
||||
func (s State) Terminal() bool {
|
||||
return s == StateReady || s == StateClosed || s == StateFailed
|
||||
}
|
||||
|
||||
// Request is one tracked new-repo request.
|
||||
type Request struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Woodpecker bool `json:"woodpecker"`
|
||||
StatusChecks []string `json:"status_checks"`
|
||||
State State `json:"state"`
|
||||
PRNumber int `json:"pr_number,omitempty"`
|
||||
PRURL string `json:"pr_url,omitempty"`
|
||||
CIEnabled bool `json:"ci_enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
// Store is a concurrency-safe map of requests keyed by id.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]Request
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New builds an empty Store.
|
||||
func New() *Store {
|
||||
return &Store{byID: map[string]Request{}, now: time.Now}
|
||||
}
|
||||
|
||||
// SetClock replaces the timestamp source; tests use it for stable output.
|
||||
func (s *Store) SetClock(now func() time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.now = now
|
||||
}
|
||||
|
||||
// NewID returns a short random request id, unique enough to name a Job.
|
||||
func NewID() string {
|
||||
var b [6]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand failing is unrecoverable; a timestamp id keeps the
|
||||
// caller's request identifiable rather than crashing the server.
|
||||
return hex.EncodeToString([]byte(time.Now().UTC().Format("150405")))
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// NewRequest builds an unstored request in StateOpeningPR. The caller stores it
|
||||
// only once the PR Job actually exists, so a failed create leaves no orphan.
|
||||
func NewRequest(id string, spec repospec.Request, now time.Time) Request {
|
||||
return Request{
|
||||
ID: id,
|
||||
Name: spec.Name,
|
||||
Description: spec.Description,
|
||||
Woodpecker: spec.Woodpecker,
|
||||
StatusChecks: spec.StatusChecks,
|
||||
State: StateOpeningPR,
|
||||
Created: now,
|
||||
Updated: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Now returns the store's clock, so callers stamp requests consistently.
|
||||
func (s *Store) Now() time.Time {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.now()
|
||||
}
|
||||
|
||||
// Put stores r verbatim, stamping Updated when anything actually changed.
|
||||
func (s *Store) Put(r Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if prev, ok := s.byID[r.ID]; ok {
|
||||
r.Created = prev.Created
|
||||
if equalIgnoringUpdated(prev, r) {
|
||||
return
|
||||
}
|
||||
} else if r.Created.IsZero() {
|
||||
r.Created = s.now()
|
||||
}
|
||||
r.Updated = s.now()
|
||||
s.byID[r.ID] = r
|
||||
}
|
||||
|
||||
// Get returns a request by id.
|
||||
func (s *Store) Get(id string) (Request, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
r, ok := s.byID[id]
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// List returns every request, most recently created first.
|
||||
func (s *Store) List() []Request {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Request, 0, len(s.byID))
|
||||
for _, r := range s.byID {
|
||||
out = append(out, r)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Created.Equal(out[j].Created) {
|
||||
return out[i].ID > out[j].ID
|
||||
}
|
||||
return out[i].Created.After(out[j].Created)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// HasActiveName reports whether a non-terminal request already claims name.
|
||||
// Two in-flight requests for the same name would race on the same branch.
|
||||
func (s *Store) HasActiveName(name string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, r := range s.byID {
|
||||
if r.Name == name && !r.State.Terminal() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func equalIgnoringUpdated(a, b Request) bool {
|
||||
a.Updated, b.Updated = time.Time{}, time.Time{}
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/repospawner/internal/repospec"
|
||||
)
|
||||
|
||||
func fixedClock(base time.Time) (*Store, func(time.Duration)) {
|
||||
s := New()
|
||||
now := base
|
||||
s.SetClock(func() time.Time { return now })
|
||||
return s, func(d time.Duration) { now = now.Add(d) }
|
||||
}
|
||||
|
||||
func TestListNewestFirst(t *testing.T) {
|
||||
s, advance := fixedClock(time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC))
|
||||
for _, name := range []string{"first", "second", "third"} {
|
||||
s.Put(NewRequest(name, repospec.Request{Name: name}, s.Now()))
|
||||
advance(time.Minute)
|
||||
}
|
||||
got := s.List()
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("List returned %d requests", len(got))
|
||||
}
|
||||
for i, want := range []string{"third", "second", "first"} {
|
||||
if got[i].Name != want {
|
||||
t.Errorf("List()[%d] = %q, want %q", i, got[i].Name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutOnlyStampsUpdatedOnChange(t *testing.T) {
|
||||
s, advance := fixedClock(time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC))
|
||||
r := NewRequest("id", repospec.Request{Name: "widget", StatusChecks: []string{"a"}}, s.Now())
|
||||
s.Put(r)
|
||||
stored, _ := s.Get("id")
|
||||
|
||||
advance(time.Hour)
|
||||
s.Put(r)
|
||||
again, _ := s.Get("id")
|
||||
if !again.Updated.Equal(stored.Updated) {
|
||||
t.Errorf("Updated moved on an unchanged Put: %v -> %v", stored.Updated, again.Updated)
|
||||
}
|
||||
|
||||
r.State = StatePROpen
|
||||
s.Put(r)
|
||||
changed, _ := s.Get("id")
|
||||
if !changed.Updated.After(stored.Updated) {
|
||||
t.Errorf("Updated did not move on a real change: %v", changed.Updated)
|
||||
}
|
||||
if !changed.Created.Equal(stored.Created) {
|
||||
t.Errorf("Created must not move: %v -> %v", stored.Created, changed.Created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasActiveName(t *testing.T) {
|
||||
s := New()
|
||||
r := NewRequest("id", repospec.Request{Name: "widget"}, time.Now())
|
||||
s.Put(r)
|
||||
if !s.HasActiveName("widget") {
|
||||
t.Error("an in-flight request must claim its name")
|
||||
}
|
||||
if s.HasActiveName("other") {
|
||||
t.Error("an unrelated name must be free")
|
||||
}
|
||||
|
||||
for _, state := range []State{StateReady, StateClosed, StateFailed} {
|
||||
r.State = state
|
||||
s.Put(r)
|
||||
if s.HasActiveName("widget") {
|
||||
t.Errorf("state %q is terminal and must release the name", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIDIsUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 1000; i++ {
|
||||
id := NewID()
|
||||
if id == "" {
|
||||
t.Fatal("NewID returned an empty id")
|
||||
}
|
||||
if seen[id] {
|
||||
t.Fatalf("NewID repeated %q", id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
s := New()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
id := NewID()
|
||||
s.Put(NewRequest(id, repospec.Request{Name: "widget"}, time.Now()))
|
||||
s.Get(id)
|
||||
s.List()
|
||||
s.HasActiveName("widget")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if len(s.List()) != 32 {
|
||||
t.Errorf("List returned %d requests, want 32", len(s.List()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user