Files
repospawner/internal/store/store.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

168 lines
4.6 KiB
Go

// 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)
}