feat(registry): 写入改走鉴权网关,构建先扫描再推送
This commit is contained in:
16 files changed
+1814
-145
No files matched your search
@@ -0,0 +1,272 @@
|
||||
// Package registrygate is the write-authorization front of the in-cluster image
|
||||
// registry. registry:2 runs with no auth of its own and listens on the pod's
|
||||
// loopback only; this gate owns the registry port and forwards to it.
|
||||
//
|
||||
// Why a gate rather than registry:2's own htpasswd auth: htpasswd is all-or-nothing
|
||||
// (every principal may write every repository, and anonymous pulls stop working),
|
||||
// while the platform needs two distinct writers and anonymous reads:
|
||||
//
|
||||
// - reads (GET/HEAD) stay anonymous, because the node's containerd pulls through
|
||||
// the loopback hostPort, Kaniko pulls FROM images and Trivy pulls its DB mirror,
|
||||
// and none of them should carry a credential;
|
||||
// - the "platform" principal (the installer) may write anything;
|
||||
// - the "build" principal (the push step of a build Job) may write any repository
|
||||
// outside the platform-reserved ones (felis/…, mirror/…), and may not delete.
|
||||
//
|
||||
// Before this gate any pod that could reach the registry — a game server running a
|
||||
// tenant's plugin, or a Dockerfile RUN step inside Kaniko — could overwrite
|
||||
// felis/felis and take over the control plane on its next pull.
|
||||
package registrygate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Principal names. They are the basic-auth usernames and the file names under the
|
||||
// gate's auth directory (cmd/felis registry-gate --auth-dir).
|
||||
const (
|
||||
PrincipalPlatform = "platform"
|
||||
PrincipalBuild = "build"
|
||||
)
|
||||
|
||||
// ReservedRepoRoots are the first path components the build principal may never
|
||||
// write: felis/ holds the control-plane and game images the platform runs, mirror/
|
||||
// holds the Trivy DB mirrors the scan gate trusts. A build that could overwrite
|
||||
// either would own the platform or blind its own scanner.
|
||||
var ReservedRepoRoots = []string{"felis", "mirror"}
|
||||
|
||||
// Realm is the basic-auth realm the gate challenges with.
|
||||
const Realm = "felis-registry"
|
||||
|
||||
// Gate authorizes registry requests and proxies the allowed ones upstream.
|
||||
type Gate struct {
|
||||
// Tokens maps a principal to its secret. A principal with an empty or missing
|
||||
// token cannot authenticate: writes fail closed while reads keep working.
|
||||
Tokens map[string]string
|
||||
// Upstream is the loopback registry, e.g. http://127.0.0.1:5001.
|
||||
Upstream *url.URL
|
||||
// Log receives one line per refused write. Nil discards.
|
||||
Log *slog.Logger
|
||||
|
||||
proxy *httputil.ReverseProxy
|
||||
health *http.Client
|
||||
}
|
||||
|
||||
// New builds a Gate for upstream.
|
||||
func New(upstream *url.URL, tokens map[string]string, log *slog.Logger) *Gate {
|
||||
g := &Gate{Tokens: tokens, Upstream: upstream, Log: log}
|
||||
rp := httputil.NewSingleHostReverseProxy(upstream)
|
||||
base := rp.Director
|
||||
rp.Director = func(r *http.Request) {
|
||||
base(r)
|
||||
// The registry has no auth of its own; the credential stops here.
|
||||
r.Header.Del("Authorization")
|
||||
}
|
||||
// Blob uploads and pulls are streamed; flush as bytes arrive so a large layer
|
||||
// pull is not buffered in the gate.
|
||||
rp.FlushInterval = -1
|
||||
g.proxy = rp
|
||||
g.health = &http.Client{Timeout: 3 * time.Second}
|
||||
return g
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/livez":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
case "/healthz":
|
||||
g.serveHealth(w, r)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(r.URL.Path, "/v2/") && r.URL.Path != "/v2" {
|
||||
writeError(w, http.StatusNotFound, "NAME_UNKNOWN", "not a registry API path")
|
||||
return
|
||||
}
|
||||
// The gate and the registry must read the same path, or an authorization check
|
||||
// on one repository could be spent on another. Refuse every shape that a later
|
||||
// clean-up or decode could turn into a different path.
|
||||
if !canonicalPath(r) {
|
||||
writeError(w, http.StatusBadRequest, "NAME_INVALID", "non-canonical request path")
|
||||
return
|
||||
}
|
||||
|
||||
principal, authErr := g.authenticate(r)
|
||||
if authErr != nil {
|
||||
challenge(w, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead:
|
||||
// The API root is where Docker-compatible clients learn which auth scheme
|
||||
// the registry wants; the daemon sends credentials on later writes only if
|
||||
// this answer challenged it. Everything else stays anonymous for reads.
|
||||
if isAPIRoot(r.URL.Path) && principal == "" {
|
||||
challenge(w, "authentication required")
|
||||
return
|
||||
}
|
||||
g.proxy.ServeHTTP(w, r)
|
||||
return
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
if principal == "" {
|
||||
challenge(w, "authentication required")
|
||||
return
|
||||
}
|
||||
if reason := Authorize(principal, r.Method, r.URL.Path); reason != "" {
|
||||
if g.Log != nil {
|
||||
g.Log.Warn("registry write refused", "principal", principal, "method", r.Method, "path", r.URL.Path, "reason", reason)
|
||||
}
|
||||
writeError(w, http.StatusForbidden, "DENIED", reason)
|
||||
return
|
||||
}
|
||||
g.proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// authenticate returns the principal the request's basic credentials name, "" for
|
||||
// an anonymous request, and an error for credentials that do not verify.
|
||||
func (g *Gate) authenticate(r *http.Request) (string, error) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
return "", nil
|
||||
}
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return "", fmt.Errorf("malformed authorization")
|
||||
}
|
||||
want, known := g.Tokens[user]
|
||||
if !known || want == "" {
|
||||
// Still spend a comparison so an unknown user is not faster to refuse.
|
||||
subtle.ConstantTimeCompare([]byte(pass), []byte("x"))
|
||||
return "", fmt.Errorf("unknown principal")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(pass), []byte(want)) != 1 {
|
||||
return "", fmt.Errorf("bad secret")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Authorize decides whether an authenticated principal may send a write request
|
||||
// for path. It returns "" to allow, or the refusal reason.
|
||||
func Authorize(principal, method, path string) string {
|
||||
switch principal {
|
||||
case PrincipalPlatform:
|
||||
return ""
|
||||
case PrincipalBuild:
|
||||
if method == http.MethodDelete {
|
||||
return "the build principal may not delete"
|
||||
}
|
||||
repo := RepoFromPath(path)
|
||||
if repo == "" {
|
||||
return "the build principal may only write to a repository"
|
||||
}
|
||||
root, _, _ := strings.Cut(repo, "/")
|
||||
for _, reserved := range ReservedRepoRoots {
|
||||
if root == reserved {
|
||||
return fmt.Sprintf("repository %s/ is reserved for the platform", reserved)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return "unknown principal"
|
||||
}
|
||||
}
|
||||
|
||||
// RepoFromPath extracts the repository name from a registry API v2 path, or ""
|
||||
// when the path addresses no repository (/v2/, /v2/_catalog). The shapes are the
|
||||
// distribution API's: <name>/manifests/<ref>, <name>/blobs/<digest>,
|
||||
// <name>/blobs/uploads/[<uuid>], <name>/tags/list, <name>/referrers/<digest>.
|
||||
// Neither a reference, a digest nor an upload id contains '/', so the repository
|
||||
// is everything before the fixed tail.
|
||||
func RepoFromPath(path string) string {
|
||||
rest, ok := strings.CutPrefix(path, "/v2/")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
seg := strings.Split(rest, "/")
|
||||
n := len(seg)
|
||||
switch {
|
||||
case n >= 4 && seg[n-3] == "blobs" && seg[n-2] == "uploads":
|
||||
return strings.Join(seg[:n-3], "/")
|
||||
case n >= 3 && (seg[n-2] == "manifests" || seg[n-2] == "blobs" || seg[n-2] == "tags" || seg[n-2] == "referrers"):
|
||||
return strings.Join(seg[:n-2], "/")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// canonicalPath rejects any request path the upstream could read differently from
|
||||
// the gate: percent-escapes Go would decode (RawPath set), dot segments a router
|
||||
// would clean, and empty segments other than the trailing slash of an upload POST.
|
||||
func canonicalPath(r *http.Request) bool {
|
||||
if r.URL.RawPath != "" && r.URL.RawPath != r.URL.Path {
|
||||
return false
|
||||
}
|
||||
p := r.URL.Path
|
||||
if strings.ContainsAny(p, "%\\") {
|
||||
return false
|
||||
}
|
||||
seg := strings.Split(strings.TrimPrefix(p, "/"), "/")
|
||||
for i, s := range seg {
|
||||
if s == "." || s == ".." {
|
||||
return false
|
||||
}
|
||||
if s == "" && i != len(seg)-1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isAPIRoot(p string) bool { return p == "/v2/" || p == "/v2" }
|
||||
|
||||
// serveHealth answers 200 when the upstream registry answers its API root, the
|
||||
// check registry:2's own probes used before the gate took its port.
|
||||
func (g *Gate) serveHealth(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.Upstream.JoinPath("/v2/").String(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
resp, err := g.health.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, "upstream: "+err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
http.Error(w, fmt.Sprintf("upstream answered %d", resp.StatusCode), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func challenge(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="`+Realm+`"`)
|
||||
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", msg)
|
||||
}
|
||||
|
||||
// writeError answers in the registry's own error envelope, which Docker and
|
||||
// containerd both surface verbatim to the operator.
|
||||
func writeError(w http.ResponseWriter, status int, code, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"errors": []map[string]string{{"code": code, "message": msg}},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package registrygate
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type upstreamLog struct {
|
||||
mu sync.Mutex
|
||||
seen []string
|
||||
auth []string
|
||||
}
|
||||
|
||||
func (u *upstreamLog) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u.mu.Lock()
|
||||
u.seen = append(u.seen, r.Method+" "+r.URL.RequestURI())
|
||||
u.auth = append(u.auth, r.Header.Get("Authorization"))
|
||||
u.mu.Unlock()
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
w.Header().Set("Location", "/v2/x/blobs/uploads/abc")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case http.MethodPut:
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (u *upstreamLog) count() int {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return len(u.seen)
|
||||
}
|
||||
|
||||
func newGate(t *testing.T) (*httptest.Server, *upstreamLog) {
|
||||
t.Helper()
|
||||
up := &upstreamLog{}
|
||||
upSrv := httptest.NewServer(up.handler())
|
||||
t.Cleanup(upSrv.Close)
|
||||
target, _ := url.Parse(upSrv.URL)
|
||||
g := New(target, map[string]string{PrincipalPlatform: "plat-secret", PrincipalBuild: "build-secret"}, nil)
|
||||
gs := httptest.NewServer(g)
|
||||
t.Cleanup(gs.Close)
|
||||
return gs, up
|
||||
}
|
||||
|
||||
func do(t *testing.T, srv *httptest.Server, method, path, user, pass string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, srv.URL+path, strings.NewReader(""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestAnonymousReadsPassButTheAPIRootChallenges(t *testing.T) {
|
||||
gs, up := newGate(t)
|
||||
for _, p := range []string{
|
||||
"/v2/felis/felis/manifests/v0.1.0",
|
||||
"/v2/felis/felis/blobs/sha256:abc",
|
||||
"/v2/mirror/trivy-db/manifests/2",
|
||||
"/v2/_catalog",
|
||||
"/v2/user-uploads/s1/tags/list",
|
||||
} {
|
||||
for _, m := range []string{http.MethodGet, http.MethodHead} {
|
||||
if resp := do(t, gs, m, p, "", ""); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("%s %s anonymous = %d, want 200", m, p, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Docker's daemon only sends credentials on a push if the API root challenged
|
||||
// it, so the root must say 401 + Basic to anonymous callers.
|
||||
resp := do(t, gs, http.MethodGet, "/v2/", "", "")
|
||||
if resp.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(resp.Header.Get("WWW-Authenticate"), "Basic ") {
|
||||
t.Fatalf("anonymous GET /v2/ = %d %q, want 401 Basic challenge", resp.StatusCode, resp.Header.Get("WWW-Authenticate"))
|
||||
}
|
||||
if resp := do(t, gs, http.MethodGet, "/v2/", PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("authenticated GET /v2/ = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if resp := do(t, gs, http.MethodGet, "/v2/", PrincipalBuild, "wrong"); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("GET /v2/ with a bad secret = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
_ = up
|
||||
}
|
||||
|
||||
func TestAnonymousWritesNeverReachTheRegistry(t *testing.T) {
|
||||
gs, up := newGate(t)
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{http.MethodPost, "/v2/felis/felis/blobs/uploads/"},
|
||||
{http.MethodPut, "/v2/felis/felis/manifests/v0.1.0"},
|
||||
{http.MethodPatch, "/v2/user-uploads/s1/blobs/uploads/abc"},
|
||||
{http.MethodDelete, "/v2/user-uploads/s1/manifests/sha256:abc"},
|
||||
} {
|
||||
resp := do(t, gs, c.method, c.path, "", "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous %s %s = %d, want 401", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
if resp := do(t, gs, c.method, c.path, PrincipalPlatform, "not-it"); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("bad-secret %s %s = %d, want 401", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
if resp := do(t, gs, c.method, c.path, "intruder", "plat-secret"); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("unknown-user %s %s = %d, want 401", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
if n := up.count(); n != 0 {
|
||||
t.Fatalf("%d refused writes reached the registry: %v", n, up.seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrincipalIsFencedOffPlatformRepos(t *testing.T) {
|
||||
gs, up := newGate(t)
|
||||
for _, p := range []string{
|
||||
"/v2/felis/felis/manifests/v0.1.0",
|
||||
"/v2/felis/limbo/blobs/uploads/",
|
||||
"/v2/felis/manifests/latest",
|
||||
"/v2/mirror/trivy-db/manifests/2",
|
||||
"/v2/mirror/trivy-java-db/blobs/uploads/abc",
|
||||
} {
|
||||
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodPatch} {
|
||||
if resp := do(t, gs, m, p, PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("build %s %s = %d, want 403", m, p, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp := do(t, gs, http.MethodDelete, "/v2/user-uploads/s1/manifests/sha256:abc", PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("build DELETE = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
if n := up.count(); n != 0 {
|
||||
t.Fatalf("%d refused writes reached the registry: %v", n, up.seen)
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
method, path string
|
||||
want int
|
||||
}{
|
||||
{http.MethodPost, "/v2/user-uploads/s1/blobs/uploads/", http.StatusAccepted},
|
||||
{http.MethodPatch, "/v2/user-uploads/s1/blobs/uploads/abc", http.StatusOK},
|
||||
{http.MethodPut, "/v2/user-uploads/s1/blobs/uploads/abc?digest=sha256:0", http.StatusCreated},
|
||||
{http.MethodPut, "/v2/user-uploads/s1/manifests/latest", http.StatusCreated},
|
||||
{http.MethodPut, "/v2/modpacks/pack/manifests/1.0", http.StatusCreated},
|
||||
// A repository merely containing "felis" deeper down is not reserved.
|
||||
{http.MethodPut, "/v2/builds/felis/manifests/1", http.StatusCreated},
|
||||
} {
|
||||
if resp := do(t, gs, c.method, c.path, PrincipalBuild, "build-secret"); resp.StatusCode != c.want {
|
||||
t.Errorf("build %s %s = %d, want %d", c.method, c.path, resp.StatusCode, c.want)
|
||||
}
|
||||
}
|
||||
for i, a := range up.auth {
|
||||
if a != "" {
|
||||
t.Errorf("request %d (%s) reached the registry with an Authorization header", i, up.seen[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformPrincipalMayWriteAndDeleteAnything(t *testing.T) {
|
||||
gs, _ := newGate(t)
|
||||
for _, c := range []struct {
|
||||
method, path string
|
||||
want int
|
||||
}{
|
||||
{http.MethodPut, "/v2/felis/felis/manifests/v0.2.0", http.StatusCreated},
|
||||
{http.MethodPost, "/v2/mirror/trivy-db/blobs/uploads/", http.StatusAccepted},
|
||||
{http.MethodDelete, "/v2/user-uploads/s1/manifests/sha256:abc", http.StatusOK},
|
||||
} {
|
||||
if resp := do(t, gs, c.method, c.path, PrincipalPlatform, "plat-secret"); resp.StatusCode != c.want {
|
||||
t.Errorf("platform %s %s = %d, want %d", c.method, c.path, resp.StatusCode, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTricksAreRefusedBeforeAuthorization(t *testing.T) {
|
||||
gs, up := newGate(t)
|
||||
for _, p := range []string{
|
||||
"/v2/user-uploads/../felis/felis/manifests/v1",
|
||||
"/v2/user-uploads/./x/manifests/v1",
|
||||
"/v2/user-uploads%2F..%2Ffelis/felis/manifests/v1",
|
||||
"/v2/user-uploads//felis/manifests/v1",
|
||||
} {
|
||||
req, _ := http.NewRequest(http.MethodPut, gs.URL, nil)
|
||||
req.URL.Opaque = p // send the path exactly as written, no client-side cleaning
|
||||
req.SetBasicAuth(PrincipalBuild, "build-secret")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("PUT %s = %d, want 400", p, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
if n := up.count(); n != 0 {
|
||||
t.Fatalf("%d crafted paths reached the registry: %v", n, up.seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingTokenFailsClosed(t *testing.T) {
|
||||
up := &upstreamLog{}
|
||||
upSrv := httptest.NewServer(up.handler())
|
||||
defer upSrv.Close()
|
||||
target, _ := url.Parse(upSrv.URL)
|
||||
gs := httptest.NewServer(New(target, map[string]string{PrincipalBuild: ""}, nil))
|
||||
defer gs.Close()
|
||||
if resp := do(t, gs, http.MethodPut, "/v2/user-uploads/s1/manifests/latest", PrincipalBuild, ""); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("empty configured token accepted an empty password: %d", resp.StatusCode)
|
||||
}
|
||||
if resp := do(t, gs, http.MethodGet, "/v2/user-uploads/s1/manifests/latest", "", ""); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("reads must keep working without tokens: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
gs, _ := newGate(t)
|
||||
if resp := do(t, gs, http.MethodGet, "/healthz", "", ""); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("healthz = %d", resp.StatusCode)
|
||||
}
|
||||
dead, _ := url.Parse("http://127.0.0.1:1")
|
||||
ds := httptest.NewServer(New(dead, nil, nil))
|
||||
defer ds.Close()
|
||||
if resp := do(t, ds, http.MethodGet, "/healthz", "", ""); resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("healthz with a dead upstream = %d, want 503", resp.StatusCode)
|
||||
}
|
||||
if resp := do(t, ds, http.MethodGet, "/livez", "", ""); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("livez = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoFromPath(t *testing.T) {
|
||||
for path, want := range map[string]string{
|
||||
"/v2/": "",
|
||||
"/v2/_catalog": "",
|
||||
"/v2/a/manifests/latest": "a",
|
||||
"/v2/a/b/c/manifests/sha256:0": "a/b/c",
|
||||
"/v2/a/blobs/sha256:0": "a",
|
||||
"/v2/a/b/blobs/uploads/": "a/b",
|
||||
"/v2/a/b/blobs/uploads/uuid-1": "a/b",
|
||||
"/v2/a/tags/list": "a",
|
||||
"/v2/user-uploads/blobs/manifests/one": "user-uploads/blobs",
|
||||
} {
|
||||
if got := RepoFromPath(path); got != want {
|
||||
t.Errorf("RepoFromPath(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user