kubo/test/cli/testutils/pinningservice/pinning.go
Gus Eggert a24cfb89a5
test: port remote pinning tests to Go (#9720)
This also means that rb-pinning-service-api is no longer required for
running remote pinning tests. This alone saves at least 3 minutes in
test runtime in CI because we don't need to checkout the repo, build
the Docker image, run it, etc.

Instead this implements a simple pinning service in Go that the test
runs in-process, with a callback that can be used to control the async
behavior of the pinning service (e.g. simulate work happening
asynchronously like transitioning from "queued" -> "pinning" ->
"pinned").

This also adds an environment variable to Kubo to control the MFS
remote pin polling interval, so that we don't have to wait 30 seconds
in the test for MFS changes to be repinned. This is purely for tests
so I don't think we should document this.

This entire test suite runs in around 2.5 sec on my laptop, compared to
the existing 3+ minutes in CI.
2023-03-30 07:46:35 -04:00

402 lines
9.3 KiB
Go

package pinningservice
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
)
func NewRouter(authToken string, svc *PinningService) http.Handler {
router := httprouter.New()
router.GET("/api/v1/pins", svc.listPins)
router.POST("/api/v1/pins", svc.addPin)
router.GET("/api/v1/pins/:requestID", svc.getPin)
router.POST("/api/v1/pins/:requestID", svc.replacePin)
router.DELETE("/api/v1/pins/:requestID", svc.removePin)
handler := authHandler(authToken, router)
return handler
}
func authHandler(authToken string, delegate http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authz := r.Header.Get("Authorization")
if !strings.HasPrefix(authz, "Bearer ") {
errResp(w, "invalid authorization token, must start with 'Bearer '", "", http.StatusBadRequest)
return
}
token := strings.TrimPrefix(authz, "Bearer ")
if token != authToken {
errResp(w, "access denied", "", http.StatusUnauthorized)
return
}
delegate.ServeHTTP(w, r)
})
}
func New() *PinningService {
return &PinningService{
PinAdded: func(*AddPinRequest, *PinStatus) {},
}
}
// PinningService is a basic pinning service that implements the Remote Pinning API, for testing Kubo's integration with remote pinning services.
// Pins are not persisted, they are just kept in-memory, and this provides callbacks for controlling the behavior of the pinning service.
type PinningService struct {
m sync.Mutex
// PinAdded is a callback that is invoked after a new pin is added via the API.
PinAdded func(*AddPinRequest, *PinStatus)
pins []*PinStatus
}
type Pin struct {
CID string `json:"cid"`
Name string `json:"name"`
Origins []string `json:"origins"`
Meta map[string]interface{} `json:"meta"`
}
type PinStatus struct {
M sync.Mutex
RequestID string
Status string
Created time.Time
Pin Pin
Delegates []string
Info map[string]interface{}
}
func (p *PinStatus) MarshalJSON() ([]byte, error) {
type pinStatusJSON struct {
RequestID string `json:"requestid"`
Status string `json:"status"`
Created time.Time `json:"created"`
Pin Pin `json:"pin"`
Delegates []string `json:"delegates"`
Info map[string]interface{} `json:"info"`
}
// lock the pin before marshaling it to protect against data races while marshaling
p.M.Lock()
pinJSON := pinStatusJSON{
RequestID: p.RequestID,
Status: p.Status,
Created: p.Created,
Pin: p.Pin,
Delegates: p.Delegates,
Info: p.Info,
}
p.M.Unlock()
return json.Marshal(pinJSON)
}
func (p *PinStatus) Clone() PinStatus {
return PinStatus{
RequestID: p.RequestID,
Status: p.Status,
Created: p.Created,
Pin: p.Pin,
Delegates: p.Delegates,
Info: p.Info,
}
}
const (
matchExact = "exact"
matchIExact = "iexact"
matchPartial = "partial"
matchIPartial = "ipartial"
statusQueued = "queued"
statusPinning = "pinning"
statusPinned = "pinned"
statusFailed = "failed"
timeLayout = "2006-01-02T15:04:05.999Z"
)
func errResp(w http.ResponseWriter, reason, details string, statusCode int) {
type errorObj struct {
Reason string `json:"reason"`
Details string `json:"details"`
}
type errorResp struct {
Error errorObj `json:"error"`
}
resp := errorResp{
Error: errorObj{
Reason: reason,
Details: details,
},
}
writeJSON(w, resp, statusCode)
}
func writeJSON(w http.ResponseWriter, val any, statusCode int) {
b, err := json.Marshal(val)
if err != nil {
w.Header().Set("Content-Type", "text/plain")
errResp(w, fmt.Sprintf("marshaling response: %s", err), "", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_, _ = w.Write(b)
}
type AddPinRequest struct {
CID string `json:"cid"`
Name string `json:"name"`
Origins []string `json:"origins"`
Meta map[string]interface{} `json:"meta"`
}
func (p *PinningService) addPin(writer http.ResponseWriter, req *http.Request, params httprouter.Params) {
var addReq AddPinRequest
err := json.NewDecoder(req.Body).Decode(&addReq)
if err != nil {
errResp(writer, fmt.Sprintf("unmarshaling req: %s", err), "", http.StatusBadRequest)
return
}
pin := &PinStatus{
RequestID: uuid.NewString(),
Status: statusQueued,
Created: time.Now(),
Pin: Pin(addReq),
}
p.m.Lock()
p.pins = append(p.pins, pin)
p.m.Unlock()
writeJSON(writer, &pin, http.StatusAccepted)
p.PinAdded(&addReq, pin)
}
type ListPinsResponse struct {
Count int `json:"count"`
Results []*PinStatus `json:"results"`
}
func (p *PinningService) listPins(writer http.ResponseWriter, req *http.Request, params httprouter.Params) {
q := req.URL.Query()
cidStr := q.Get("cid")
name := q.Get("name")
match := q.Get("match")
status := q.Get("status")
beforeStr := q.Get("before")
afterStr := q.Get("after")
limitStr := q.Get("limit")
metaStr := q.Get("meta")
if limitStr == "" {
limitStr = "10"
}
limit, err := strconv.Atoi(limitStr)
if err != nil {
errResp(writer, fmt.Sprintf("parsing limit: %s", err), "", http.StatusBadRequest)
return
}
var cids []string
if cidStr != "" {
cids = strings.Split(cidStr, ",")
}
var statuses []string
if status != "" {
statuses = strings.Split(status, ",")
}
p.m.Lock()
defer p.m.Unlock()
var pins []*PinStatus
for _, pinStatus := range p.pins {
// clone it so we can immediately release the lock
pinStatus.M.Lock()
clonedPS := pinStatus.Clone()
pinStatus.M.Unlock()
// cid
var matchesCID bool
if len(cids) == 0 {
matchesCID = true
} else {
for _, cid := range cids {
if cid == clonedPS.Pin.CID {
matchesCID = true
}
}
}
if !matchesCID {
continue
}
// name
if match == "" {
match = matchExact
}
if name != "" {
switch match {
case matchExact:
if name != clonedPS.Pin.Name {
continue
}
case matchIExact:
if !strings.EqualFold(name, clonedPS.Pin.Name) {
continue
}
case matchPartial:
if !strings.Contains(clonedPS.Pin.Name, name) {
continue
}
case matchIPartial:
if !strings.Contains(strings.ToLower(clonedPS.Pin.Name), strings.ToLower(name)) {
continue
}
default:
errResp(writer, fmt.Sprintf("unknown match %q", match), "", http.StatusBadRequest)
return
}
}
// status
var matchesStatus bool
if len(statuses) == 0 {
statuses = []string{statusPinned}
}
for _, status := range statuses {
if status == clonedPS.Status {
matchesStatus = true
}
}
if !matchesStatus {
continue
}
// before
if beforeStr != "" {
before, err := time.Parse(timeLayout, beforeStr)
if err != nil {
errResp(writer, fmt.Sprintf("parsing before: %s", err), "", http.StatusBadRequest)
return
}
if !clonedPS.Created.Before(before) {
continue
}
}
// after
if afterStr != "" {
after, err := time.Parse(timeLayout, afterStr)
if err != nil {
errResp(writer, fmt.Sprintf("parsing before: %s", err), "", http.StatusBadRequest)
return
}
if !clonedPS.Created.After(after) {
continue
}
}
// meta
if metaStr != "" {
meta := map[string]interface{}{}
err := json.Unmarshal([]byte(metaStr), &meta)
if err != nil {
errResp(writer, fmt.Sprintf("parsing meta: %s", err), "", http.StatusBadRequest)
return
}
var matchesMeta bool
for k, v := range meta {
pinV, contains := clonedPS.Pin.Meta[k]
if !contains || !reflect.DeepEqual(pinV, v) {
matchesMeta = false
break
}
}
if !matchesMeta {
continue
}
}
// add the original pin status, not the cloned one
pins = append(pins, pinStatus)
if len(pins) == limit {
break
}
}
out := ListPinsResponse{
Count: len(pins),
Results: pins,
}
writeJSON(writer, out, http.StatusOK)
}
func (p *PinningService) getPin(writer http.ResponseWriter, req *http.Request, params httprouter.Params) {
requestID := params.ByName("requestID")
p.m.Lock()
defer p.m.Unlock()
for _, pin := range p.pins {
if pin.RequestID == requestID {
writeJSON(writer, pin, http.StatusOK)
return
}
}
errResp(writer, "", "", http.StatusNotFound)
}
func (p *PinningService) replacePin(writer http.ResponseWriter, req *http.Request, params httprouter.Params) {
requestID := params.ByName("requestID")
var replaceReq Pin
err := json.NewDecoder(req.Body).Decode(&replaceReq)
if err != nil {
errResp(writer, fmt.Sprintf("decoding request: %s", err), "", http.StatusBadRequest)
return
}
p.m.Lock()
defer p.m.Unlock()
for _, pin := range p.pins {
if pin.RequestID == requestID {
pin.M.Lock()
pin.Pin = replaceReq
pin.M.Unlock()
writer.WriteHeader(http.StatusAccepted)
return
}
}
errResp(writer, "", "", http.StatusNotFound)
}
func (p *PinningService) removePin(writer http.ResponseWriter, req *http.Request, params httprouter.Params) {
requestID := params.ByName("requestID")
p.m.Lock()
defer p.m.Unlock()
for i, pin := range p.pins {
if pin.RequestID == requestID {
p.pins = append(p.pins[0:i], p.pins[i+1:]...)
writer.WriteHeader(http.StatusAccepted)
return
}
}
errResp(writer, "", "", http.StatusNotFound)
}