Files
ttl.sh/sidecar/internal/server/server.go
T

105 lines
3.1 KiB
Go

// Package server implements the HTTP surface for the TTL service: a CloudEvents
// sink for zot image events and a health check.
package server
import (
"io"
"log"
"net/http"
"time"
"github.com/nullbytelabs/zot-ephemeral-ttl/internal/events"
"github.com/nullbytelabs/zot-ephemeral-ttl/internal/ttl"
)
// Store is the subset of the persistence layer the server needs.
// Implementations must be safe for concurrent use: the HTTP handlers call it
// from whatever goroutine net/http hands them, without serializing first.
type Store interface {
Upsert(repo, tag, digest string, expiresAt, createdAt time.Time) error
}
// Server is the HTTP server: events sink + healthz. It holds no lock of its
// own; concurrent event POSTs are safe by way of the Store contract above.
type Server struct {
store Store
defaultTTL time.Duration
maxTTL time.Duration
clock func() time.Time
}
// New constructs a Server. defaultTTL and maxTTL drive the expiry policy applied
// to incoming events.
func New(st Store, defaultTTL, maxTTL time.Duration, clock func() time.Time) *Server {
return &Server{
store: st,
defaultTTL: defaultTTL,
maxTTL: maxTTL,
clock: clock,
}
}
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "read body: "+err.Error(), http.StatusBadRequest)
return
}
evt, err := events.Parse(r, body)
if err != nil {
log.Printf("events: parse error: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if evt.Type != events.ImageUpdatedType {
w.WriteHeader(http.StatusNoContent)
return
}
if evt.Data.Name == "" || evt.Data.Reference == "" {
log.Printf("events: image.updated missing name/reference; ignoring (digest=%q)", evt.Data.Digest)
w.WriteHeader(http.StatusNoContent)
return
}
now := s.clock()
expires := ttl.ComputeExpiry(evt.Data.Reference, now, s.defaultTTL, s.maxTTL)
err = s.store.Upsert(evt.Data.Name, evt.Data.Reference, evt.Data.Digest, expires, now)
if err != nil {
log.Printf("events: upsert %s:%s: %v", evt.Data.Name, evt.Data.Reference, err)
http.Error(w, "store error", http.StatusInternalServerError)
return
}
log.Printf("events: recorded %s:%s digest=%s expires_at=%s",
evt.Data.Name, evt.Data.Reference, shortDigest(evt.Data.Digest), expires.UTC().Format(time.RFC3339))
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok\n")
}
// Routes returns the HTTP handler wiring the server's endpoints.
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/events", s.handleEvents)
mux.HandleFunc("/healthz", s.handleHealthz)
return mux
}
func shortDigest(d string) string {
if len(d) > 19 { // "sha256:" + 12 hex chars
return d[:19]
}
return d
}