ac8be0a105
OS package remotes (rpm/deb/apk) fetch many small files and benefit from
spreading upstream load across mirrors and surviving a mirror outage. A
remote may now set a `mirrorlist` of additional upstream base URLs; the
effective upstream pool is [base_url] + mirrorlist, which the shared proxy
engine load-balances round-robin and, on a network error/timeout/5xx,
fails over to the next mirror before returning an error. Because selection
happens in the engine, it works for every provider that reaches upstream.
Backward compatible: `base_url` stays a plain string that providers read
unchanged, and a remote with no mirrorlist behaves exactly as today
(single attempt, same error path).
- add models.Remote.Mirrorlist ([]string, json "mirrorlist,omitempty") and
UpstreamPool() = [base_url] + mirrorlist; ValidateMirrorlist enforces
remote repo_type + package_type in {rpm, deb, alpine} and http/https URLs
- v2 create/update: reject a mirrorlist on any other repo (400); base_url
remains required for remotes
- persist mirrorlist in a new additive `mirrorlist TEXT[]` column
(remoteCols/scanRemote/CreateRemote/UpdateRemote); base_url column
unchanged
- engine: per-remote round-robin cursor over the pool; wrap the
fetch/head/revalidate upstream calls in a failover loop that narrows the
remote to one selected mirror per attempt; only network errors and 5xx
fail over (404/403/... return as-is); circuit breaker stays keyed per
remote and trips only after all mirrors fail
- tests: model JSON round-trip + validation gating, engine
round-robin/failover/no-mirrorlist-unchanged, DB mirrorlist round-trip,
and a docker acceptance suite (round-robin across two mock upstreams,
failover past a dead primary, no-mirrorlist regression, and a real dnf
makecache+install through a two-mirror rpm remote whose base_url is dead)
Least-connections and a per-remote strategy selector are a follow-up PR.
138 lines
4.0 KiB
Go
138 lines
4.0 KiB
Go
package v2
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.unkin.net/unkin/artifactapi/internal/database"
|
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
|
)
|
|
|
|
// Primer enqueues a background metadata prime for a newly created remote so the
|
|
// create call never blocks on a derive. *rpm.Syncer and *deb.Syncer satisfy it.
|
|
type Primer interface {
|
|
EnqueuePrime(remote models.Remote)
|
|
}
|
|
|
|
type RemotesHandler struct {
|
|
db *database.DB
|
|
primers map[models.PackageType]Primer
|
|
}
|
|
|
|
// NewRemotesHandler wires the handler to the per-type metadata primers. primers
|
|
// may be nil; a package type with no registered primer simply skips priming.
|
|
func NewRemotesHandler(db *database.DB, primers map[models.PackageType]Primer) *RemotesHandler {
|
|
return &RemotesHandler{db: db, primers: primers}
|
|
}
|
|
|
|
func (h *RemotesHandler) Routes() chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Get("/", h.list)
|
|
r.Post("/", h.create)
|
|
r.Get("/{name}", h.get)
|
|
r.Put("/{name}", h.update)
|
|
r.Delete("/{name}", h.del)
|
|
return r
|
|
}
|
|
|
|
func (h *RemotesHandler) list(w http.ResponseWriter, r *http.Request) {
|
|
remotes, err := h.db.ListRemotes(r.Context())
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, remotes)
|
|
}
|
|
|
|
func (h *RemotesHandler) get(w http.ResponseWriter, r *http.Request) {
|
|
name := chi.URLParam(r, "name")
|
|
remote, err := h.db.GetRemote(r.Context(), name)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("remote %q not found", name), http.StatusNotFound)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, remote)
|
|
}
|
|
|
|
func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
|
|
var remote models.Remote
|
|
if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
|
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !remote.PackageType.Valid() {
|
|
http.Error(w, fmt.Sprintf("invalid package type: %q", remote.PackageType), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if remote.RepoType == "" {
|
|
remote.RepoType = models.RepoTypeRemote
|
|
}
|
|
if !remote.RepoType.Valid() {
|
|
http.Error(w, fmt.Sprintf("invalid repo type: %q", remote.RepoType), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if remote.RepoType == models.RepoTypeRemote && remote.BaseURL == "" {
|
|
http.Error(w, "base_url is required for remote repositories", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := remote.ValidateMirrorlist(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := remote.ValidatePatterns(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := h.db.CreateRemote(r.Context(), &remote); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Prime a metadata-only remote (github_rpm/github_deb) in the background so
|
|
// its first index request is served from cache instead of a cold derive.
|
|
if primer := h.primers[remote.PackageType]; primer != nil {
|
|
primer.EnqueuePrime(remote)
|
|
}
|
|
writeJSON(w, http.StatusCreated, remote)
|
|
}
|
|
|
|
func (h *RemotesHandler) update(w http.ResponseWriter, r *http.Request) {
|
|
name := chi.URLParam(r, "name")
|
|
var remote models.Remote
|
|
if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
|
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
remote.Name = name
|
|
if err := remote.ValidateMirrorlist(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := remote.ValidatePatterns(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := h.db.UpdateRemote(r.Context(), &remote); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, remote)
|
|
}
|
|
|
|
func (h *RemotesHandler) del(w http.ResponseWriter, r *http.Request) {
|
|
name := chi.URLParam(r, "name")
|
|
if err := h.db.DeleteRemote(r.Context(), name); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|