Files
artifactapi/internal/api/v1/puppet_virtual.go
T
unkin-agent 3da8545464 Add puppet virtual repositories (#160)
Clients need one Forge to install internal modules and forgeapi.puppet.com ones with dependencies resolved across both; puppet has no virtual type.

- merge member `/v3/releases?module=` lists newest first, the earlier member winning a duplicate version
- cache merges, and modules no member has, for the merge TTL
- paginate over the merged list with Forge-root-relative links and `file_uri`
- serve `/v3/modules/<slug>` with the highest merged version as `current_release`
- 302 release details and downloads to the owning member; fail closed with 502 on a member error
- add docs/puppet/virtual.md and an e2e-docker case driving real `puppet module install` and r10k

Reviewed-on: #160
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-20 22:19:03 +11:00

78 lines
2.3 KiB
Go

package v1
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"git.unkin.net/unkin/artifactapi/internal/provider/puppet"
"git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// serveVirtualPuppet serves the merged Forge v3 module and release listings
// and 302s release details and downloads to the member owning that release.
func (h *ProxyHandler) serveVirtualPuppet(w http.ResponseWriter, r *http.Request, virt models.Virtual, path, proxyBaseURL string) {
var doc any
var err error
switch {
case path == "v3/releases":
q := r.URL.Query()
slug := puppet.NormalizeSlug(q.Get("module"))
if !puppet.ValidSlug(slug) {
forgeError(w, http.StatusBadRequest, "module is required")
return
}
var d *virtual.PuppetDoc
d, err = h.virtualEngine.PuppetReleases(r.Context(), virt, slug)
releases := []any{}
if errors.Is(err, virtual.ErrNotFound) {
err = nil
} else if err == nil {
for _, rel := range d.Releases {
releases = append(releases, rel)
}
}
doc = puppet.ReleasesPage(slug, releases, q)
case strings.HasPrefix(path, "v3/modules/"):
slug := puppet.NormalizeSlug(strings.TrimPrefix(path, "v3/modules/"))
if !puppet.ValidSlug(slug) {
puppet.NotFound(w)
return
}
var d *virtual.PuppetDoc
if d, err = h.virtualEngine.PuppetReleases(r.Context(), virt, slug); err == nil {
doc, err = virtual.PuppetModule(d)
}
default:
var loc string
loc, err = h.virtualEngine.PuppetRedirect(r.Context(), virt, path, proxyBaseURL)
if err == nil {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Host, X-Forwarded-Proto")
http.Redirect(w, r, loc, http.StatusFound)
return
}
}
switch {
case errors.Is(err, virtual.ErrNotFound):
puppet.NotFound(w)
case errors.Is(err, virtual.ErrBadPath):
forgeError(w, http.StatusBadRequest, err.Error())
case err != nil:
slog.Error("virtual puppet fetch failed", "virtual", virt.Name, "path", path, "error", err)
forgeError(w, http.StatusBadGateway, "bad gateway")
default:
w.Header().Set("X-Artifact-Source", "virtual")
puppet.WriteJSON(w, doc)
}
}
func forgeError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]any{"message": http.StatusText(code), "errors": []string{msg}})
}