211 lines
6.5 KiB
Go
211 lines
6.5 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
|
"gitea.maximumdirect.net/eric/distributor/internal/ingest"
|
|
)
|
|
|
|
type uploadCoordinator interface {
|
|
CanAccept() bool
|
|
Submit(context.Context, UploadRequest) (UploadRunRecord, error)
|
|
Status(UploadRunID) (UploadRunRecord, bool)
|
|
}
|
|
|
|
type uploadHTTPHandler struct {
|
|
coordinator uploadCoordinator
|
|
tokens map[string]string
|
|
}
|
|
|
|
type uploadAcceptedResponse struct {
|
|
RunID UploadRunID `json:"run_id"`
|
|
Status UploadStatus `json:"status"`
|
|
}
|
|
|
|
type httpErrorResponse struct {
|
|
Error string `json:"error"`
|
|
Retryable bool `json:"retryable,omitempty"`
|
|
}
|
|
|
|
const idempotencyKeyHeader = "Idempotency-Key"
|
|
|
|
func newUploadHTTPHandler(ctx context.Context, cfg config.Config, environment config.Environment) (http.Handler, error) {
|
|
config.ApplyDefaults(&cfg)
|
|
tokens, err := resolveUploadTokens(cfg, environment)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return uploadHTTPHandler{
|
|
coordinator: NewUploadCoordinator(ctx, cfg),
|
|
tokens: tokens,
|
|
}, nil
|
|
}
|
|
|
|
func resolveUploadTokens(cfg config.Config, environment config.Environment) (map[string]string, error) {
|
|
tokens := make(map[string]string)
|
|
for _, uploadToken := range cfg.UploadTokens {
|
|
token, ok := environment.Lookup(uploadToken.TokenEnv)
|
|
if !ok {
|
|
return nil, fmt.Errorf("upload token environment variable %s is not set", uploadToken.TokenEnv)
|
|
}
|
|
if token == "" {
|
|
return nil, fmt.Errorf("upload token environment variable %s is empty", uploadToken.TokenEnv)
|
|
}
|
|
if len(uploadToken.AllowPipelines) != 1 {
|
|
return nil, fmt.Errorf("upload token %s must allow exactly one pipeline for legacy upload routing", uploadToken.ID)
|
|
}
|
|
if existing, exists := tokens[token]; exists {
|
|
return nil, fmt.Errorf("upload token environment variables for pipelines %s and %s resolve to the same value", existing, uploadToken.AllowPipelines[0])
|
|
}
|
|
tokens[token] = uploadToken.AllowPipelines[0]
|
|
}
|
|
return tokens, nil
|
|
}
|
|
|
|
func (handler uploadHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == http.MethodGet && r.URL.Path == "/healthz":
|
|
handler.handleHealth(w)
|
|
case r.Method == http.MethodPost && r.URL.Path == "/upload":
|
|
handler.handleUpload(w, r)
|
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/runs/"):
|
|
handler.handleRunStatus(w, r)
|
|
default:
|
|
writeHTTPError(w, http.StatusNotFound, "not found")
|
|
}
|
|
}
|
|
|
|
func (handler uploadHTTPHandler) handleHealth(w http.ResponseWriter) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (handler uploadHTTPHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Has("pipeline") || r.URL.Query().Has("pipeline_id") {
|
|
writeHTTPError(w, http.StatusBadRequest, "pipeline id is not accepted")
|
|
return
|
|
}
|
|
pipelineID, ok := handler.authenticate(r.Header.Get("Authorization"))
|
|
if !ok {
|
|
writeHTTPError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
contentType := r.Header.Get("Content-Type")
|
|
if err := ingest.ValidateContentType(contentType); err != nil {
|
|
writeHTTPError(w, http.StatusUnsupportedMediaType, "unsupported content type")
|
|
return
|
|
}
|
|
idempotencyKey, err := uploadIdempotencyKey(r.Header)
|
|
if err != nil {
|
|
writeHTTPError(w, http.StatusBadRequest, "invalid idempotency key")
|
|
return
|
|
}
|
|
record, err := handler.coordinator.Submit(r.Context(), UploadRequest{
|
|
PipelineID: pipelineID,
|
|
ContentType: contentType,
|
|
Body: r.Body,
|
|
IdempotencyKey: idempotencyKey,
|
|
})
|
|
if err != nil {
|
|
writeUploadSubmitError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, uploadAcceptedResponse{
|
|
RunID: record.ID,
|
|
Status: UploadStatusAccepted,
|
|
})
|
|
}
|
|
|
|
func (handler uploadHTTPHandler) handleRunStatus(w http.ResponseWriter, r *http.Request) {
|
|
rawRunID := strings.TrimPrefix(r.URL.Path, "/runs/")
|
|
if rawRunID == "" || strings.Contains(rawRunID, "/") {
|
|
writeHTTPError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
record, ok := handler.coordinator.Status(UploadRunID(rawRunID))
|
|
if !ok {
|
|
writeHTTPError(w, http.StatusNotFound, "run not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, record)
|
|
}
|
|
|
|
func (handler uploadHTTPHandler) authenticate(header string) (string, bool) {
|
|
const prefix = "Bearer "
|
|
if !strings.HasPrefix(header, prefix) {
|
|
return "", false
|
|
}
|
|
token := strings.TrimSpace(strings.TrimPrefix(header, prefix))
|
|
if token == "" {
|
|
return "", false
|
|
}
|
|
pipelineID, ok := handler.tokens[token]
|
|
return pipelineID, ok
|
|
}
|
|
|
|
func uploadIdempotencyKey(header http.Header) (string, error) {
|
|
values := header.Values(idempotencyKeyHeader)
|
|
if len(values) == 0 {
|
|
return "", nil
|
|
}
|
|
if len(values) != 1 {
|
|
return "", fmt.Errorf("idempotency key must appear at most once")
|
|
}
|
|
key := values[0]
|
|
if key == "" {
|
|
return "", fmt.Errorf("idempotency key is required when header is present")
|
|
}
|
|
if len(key) > 128 {
|
|
return "", fmt.Errorf("idempotency key must be at most 128 bytes")
|
|
}
|
|
for index := 0; index < len(key); index++ {
|
|
character := key[index]
|
|
if character >= 'a' && character <= 'z' ||
|
|
character >= 'A' && character <= 'Z' ||
|
|
character >= '0' && character <= '9' ||
|
|
character == '.' ||
|
|
character == '_' ||
|
|
character == '-' ||
|
|
character == ':' {
|
|
continue
|
|
}
|
|
return "", fmt.Errorf("idempotency key contains unsupported character")
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func writeUploadSubmitError(w http.ResponseWriter, err error) {
|
|
var idempotencyConflict UploadIdempotencyConflictError
|
|
switch {
|
|
case IsUploadQueueFull(err):
|
|
writeHTTPError(w, http.StatusServiceUnavailable, "upload queue is full")
|
|
case errors.As(err, &idempotencyConflict):
|
|
writeHTTPErrorRetryable(w, http.StatusConflict, idempotencyConflict.Error(), idempotencyConflict.Retryable)
|
|
case errors.Is(err, ingest.ErrUploadTooLarge):
|
|
writeHTTPError(w, http.StatusRequestEntityTooLarge, "upload exceeds maximum size")
|
|
case errors.Is(err, ingest.ErrUnsupportedContentType):
|
|
writeHTTPError(w, http.StatusUnsupportedMediaType, "unsupported content type")
|
|
default:
|
|
writeHTTPError(w, http.StatusBadRequest, "upload rejected")
|
|
}
|
|
}
|
|
|
|
func writeHTTPError(w http.ResponseWriter, status int, message string) {
|
|
writeHTTPErrorRetryable(w, status, message, false)
|
|
}
|
|
|
|
func writeHTTPErrorRetryable(w http.ResponseWriter, status int, message string, retryable bool) {
|
|
writeJSON(w, status, httpErrorResponse{Error: message, Retryable: retryable})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(value)
|
|
}
|