zot: initial commit

This commit is contained in:
Ramkumar Chinchani
2019-06-20 16:36:40 -07:00
parent 967ff15637
commit 9d4e8b4594
55 changed files with 6478 additions and 2 deletions
+47
View File
@@ -0,0 +1,47 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "go_default_library",
srcs = [
"auth.go",
"config.go",
"controller.go",
"errors.go",
"log.go",
"routes.go",
],
importpath = "github.com/anuvu/zot/pkg/api",
visibility = ["//visibility:public"],
deps = [
"//docs:go_default_library",
"//errors:go_default_library",
"//pkg/storage:go_default_library",
"@com_github_gin_gonic_gin//:go_default_library",
"@com_github_opencontainers_distribution_spec//:go_default_library",
"@com_github_opencontainers_image_spec//specs-go/v1:go_default_library",
"@com_github_rs_zerolog//:go_default_library",
"@com_github_swaggo_gin_swagger//:go_default_library",
"@com_github_swaggo_gin_swagger//swaggerFiles:go_default_library",
"@org_golang_x_crypto//bcrypt:go_default_library",
],
)
go_test(
name = "go_default_test",
timeout = "short",
srcs = [
"controller_test.go",
"routes_test.go",
],
data = [
"//:exported_testdata",
],
embed = [":go_default_library"],
race = "on",
deps = [
"@com_github_opencontainers_go_digest//:go_default_library",
"@com_github_opencontainers_image_spec//specs-go/v1:go_default_library",
"@com_github_smartystreets_goconvey//convey:go_default_library",
"@in_gopkg_resty_v1//:go_default_library",
],
)
+91
View File
@@ -0,0 +1,91 @@
package api
import (
"bufio"
"encoding/base64"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
func authFail(ginCtx *gin.Context, realm string, delay int) {
time.Sleep(time.Duration(delay) * time.Second)
ginCtx.Header("WWW-Authenticate", realm)
ginCtx.AbortWithStatusJSON(http.StatusUnauthorized, NewError(UNAUTHORIZED))
}
func BasicAuthHandler(c *Controller) gin.HandlerFunc {
if c.Config.HTTP.Auth.HTPasswd.Path == "" {
// no authentication
return func(ginCtx *gin.Context) {
}
}
realm := c.Config.HTTP.Realm
if realm == "" {
realm = "Authorization Required"
}
realm = "Basic realm=" + strconv.Quote(realm)
delay := c.Config.HTTP.Auth.FailDelay
credMap := make(map[string]string)
f, err := os.Open(c.Config.HTTP.Auth.HTPasswd.Path)
if err != nil {
panic(err)
}
for {
r := bufio.NewReader(f)
line, err := r.ReadString('\n')
if err != nil {
break
}
tokens := strings.Split(line, ":")
credMap[tokens[0]] = tokens[1]
}
return func(ginCtx *gin.Context) {
basicAuth := ginCtx.Request.Header.Get("Authorization")
if basicAuth == "" {
authFail(ginCtx, realm, delay)
return
}
s := strings.SplitN(basicAuth, " ", 2)
if len(s) != 2 || strings.ToLower(s[0]) != "basic" {
authFail(ginCtx, realm, delay)
return
}
b, err := base64.StdEncoding.DecodeString(s[1])
if err != nil {
authFail(ginCtx, realm, delay)
return
}
pair := strings.SplitN(string(b), ":", 2)
if len(pair) != 2 {
authFail(ginCtx, realm, delay)
return
}
username := pair[0]
passphrase := pair[1]
passphraseHash, ok := credMap[username]
if !ok {
authFail(ginCtx, realm, delay)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passphraseHash), []byte(passphrase)); err != nil {
authFail(ginCtx, realm, delay)
return
}
}
}
+52
View File
@@ -0,0 +1,52 @@
package api
import (
dspec "github.com/opencontainers/distribution-spec"
)
type StorageConfig struct {
RootDirectory string
}
type TLSConfig struct {
Cert string
Key string
CACert string
}
type AuthHTPasswd struct {
Path string
}
type AuthConfig struct {
FailDelay int
HTPasswd AuthHTPasswd
}
type HTTPConfig struct {
Address string
Port string
TLS TLSConfig `mapstructure:",omitempty"`
Auth AuthConfig `mapstructure:",omitempty"`
Realm string
}
type LogConfig struct {
Level string
Output string
}
type Config struct {
Version string
Storage StorageConfig
HTTP HTTPConfig
Log LogConfig `mapstructure:",omitempty"`
}
func NewConfig() *Config {
return &Config{
Version: dspec.Version,
HTTP: HTTPConfig{Address: "127.0.0.1", Port: "8080"},
Log: LogConfig{Level: "debug"},
}
}
+69
View File
@@ -0,0 +1,69 @@
package api
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io/ioutil"
"net"
"net/http"
"github.com/anuvu/zot/pkg/storage"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
type Controller struct {
Config *Config
Router *gin.Engine
ImageStore *storage.ImageStore
Log zerolog.Logger
Server *http.Server
}
func NewController(config *Config) *Controller {
return &Controller{Config: config, Log: NewLogger(config)}
}
func (c *Controller) Run() error {
if c.Config.Log.Level == "debug" {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
engine := gin.New()
engine.Use(gin.Recovery(), Logger(c.Log))
c.Router = engine
_ = NewRouteHandler(c)
c.Log.Info().Interface("params", c.Config).Msg("configuration settings")
c.ImageStore = storage.NewImageStore(c.Config.Storage.RootDirectory, c.Log)
addr := fmt.Sprintf("%s:%s", c.Config.HTTP.Address, c.Config.HTTP.Port)
server := &http.Server{Addr: addr, Handler: c.Router}
c.Server = server
// Create the listener
l, err := net.Listen("tcp", addr)
if err != nil {
return err
}
if c.Config.HTTP.TLS.Key != "" && c.Config.HTTP.TLS.Cert != "" {
if c.Config.HTTP.TLS.CACert != "" {
caCert, err := ioutil.ReadFile(c.Config.HTTP.TLS.CACert)
if err != nil {
panic(err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
server.TLSConfig = &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: caCertPool,
}
}
return server.ServeTLS(l, c.Config.HTTP.TLS.Cert, c.Config.HTTP.TLS.Key)
}
return server.Serve(l)
}
+237
View File
@@ -0,0 +1,237 @@
package api_test
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"io/ioutil"
"os"
"testing"
"time"
"github.com/anuvu/zot/pkg/api"
. "github.com/smartystreets/goconvey/convey"
"gopkg.in/resty.v1"
)
const (
BaseURL1 = "http://127.0.0.1:8081"
BaseURL2 = "http://127.0.0.1:8082"
BaseSecureURL2 = "https://127.0.0.1:8082"
username = "test"
passphrase = "test"
htpasswdPath = "../../test/data/htpasswd" // nolint (gosec) - this is just test data
)
func TestNew(t *testing.T) {
Convey("Make a new controller", t, func() {
config := api.NewConfig()
So(config, ShouldNotBeNil)
So(api.NewController(config), ShouldNotBeNil)
})
}
func TestBasicAuth(t *testing.T) {
Convey("Make a new controller", t, func() {
config := api.NewConfig()
config.HTTP.Port = "8081"
config.HTTP.Auth.HTPasswd.Path = htpasswdPath
c := api.NewController(config)
dir, err := ioutil.TempDir("", "oci-repo-test")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
c.Config.Storage.RootDirectory = dir
go func() {
// this blocks
if err := c.Run(); err != nil {
return
}
}()
// wait till ready
for {
_, err := resty.R().Get(BaseURL1)
if err == nil {
break
}
time.Sleep(100 * time.Millisecond)
}
defer func() {
ctx := context.Background()
_ = c.Server.Shutdown(ctx)
}()
// without creds, should get access error
resp, err := resty.R().Get(BaseURL1)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 401)
var e api.Error
err = json.Unmarshal(resp.Body(), &e)
So(err, ShouldBeNil)
// with creds, should get expected status code
resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(BaseURL1)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 404)
resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(BaseURL1 + "/v2/")
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
})
}
func TestTLSWithBasicAuth(t *testing.T) {
Convey("Make a new controller", t, func() {
caCert, err := ioutil.ReadFile("../../test/data/ca.crt")
So(err, ShouldBeNil)
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool})
defer func() { resty.SetTLSClientConfig(nil) }()
config := api.NewConfig()
config.HTTP.Port = "8082"
config.HTTP.Auth.HTPasswd.Path = htpasswdPath
config.HTTP.TLS.Cert = "../../test/data/server.crt"
config.HTTP.TLS.Key = "../../test/data/server.key"
c := api.NewController(config)
dir, err := ioutil.TempDir("", "oci-repo-test")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
c.Config.Storage.RootDirectory = dir
go func() {
// this blocks
if err := c.Run(); err != nil {
return
}
}()
// wait till ready
for {
_, err := resty.R().Get(BaseURL2)
if err == nil {
break
}
time.Sleep(100 * time.Millisecond)
}
defer func() {
ctx := context.Background()
_ = c.Server.Shutdown(ctx)
}()
// accessing insecure HTTP site should fail
resp, err := resty.R().Get(BaseURL2)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 400)
// without creds, should get access error
resp, err = resty.R().Get(BaseSecureURL2)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 401)
var e api.Error
err = json.Unmarshal(resp.Body(), &e)
So(err, ShouldBeNil)
// with creds, should get expected status code
resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(BaseSecureURL2)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 404)
resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(BaseSecureURL2 + "/v2/")
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
})
}
func TestTLSMutualAuth(t *testing.T) {
Convey("Make a new controller", t, func() {
caCert, err := ioutil.ReadFile("../../test/data/ca.crt")
So(err, ShouldBeNil)
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool})
defer func() { resty.SetTLSClientConfig(nil) }()
config := api.NewConfig()
config.HTTP.Port = "8082"
config.HTTP.Auth.HTPasswd.Path = htpasswdPath
config.HTTP.TLS.Cert = "../../test/data/server.crt"
config.HTTP.TLS.Key = "../../test/data/server.key"
config.HTTP.TLS.CACert = "../../test/data/ca.crt"
c := api.NewController(config)
dir, err := ioutil.TempDir("", "oci-repo-test")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
c.Config.Storage.RootDirectory = dir
go func() {
// this blocks
if err := c.Run(); err != nil {
return
}
}()
// wait till ready
for {
_, err := resty.R().Get(BaseURL2)
if err == nil {
break
}
time.Sleep(100 * time.Millisecond)
}
defer func() {
ctx := context.Background()
_ = c.Server.Shutdown(ctx)
}()
// accessing insecure HTTP site should fail
resp, err := resty.R().Get(BaseURL2)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 400)
// without client certs and creds, should get conn error
_, err = resty.R().Get(BaseSecureURL2)
So(err, ShouldNotBeNil)
// with creds but without certs, should get conn error
_, err = resty.R().SetBasicAuth(username, passphrase).Get(BaseSecureURL2)
So(err, ShouldNotBeNil)
// setup TLS mutual auth
cert, err := tls.LoadX509KeyPair("../../test/data/client.crt", "../../test/data/client.key")
So(err, ShouldBeNil)
resty.SetCertificates(cert)
defer func() { resty.SetCertificates(tls.Certificate{}) }()
// with client certs but without creds, should get access error
resp, err = resty.R().Get(BaseSecureURL2)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 401)
// with client certs and creds, should get expected status code
resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(BaseSecureURL2)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 404)
resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(BaseSecureURL2 + "/v2/")
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
})
}
+142
View File
@@ -0,0 +1,142 @@
package api
import "github.com/anuvu/zot/errors"
type Error struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Description string `json:"description"`
Detail interface{} `json:"detail,omitempty"`
}
type ErrorList struct {
Errors []*Error `json:"errors"`
}
type ErrorCode int
// nolint (golint)
const (
BLOB_UNKNOWN ErrorCode = iota
BLOB_UPLOAD_INVALID
BLOB_UPLOAD_UNKNOWN
DIGEST_INVALID
MANIFEST_BLOB_UNKNOWN
MANIFEST_INVALID
MANIFEST_UNKNOWN
MANIFEST_UNVERIFIED
NAME_INVALID
NAME_UNKNOWN
SIZE_INVALID
TAG_INVALID
UNAUTHORIZED
DENIED
UNSUPPORTED
)
func NewError(code ErrorCode, detail ...interface{}) Error {
var errMap = map[ErrorCode]Error{
BLOB_UNKNOWN: {
Message: "blob unknown to registry",
Description: "blob unknown to registry This error MAY be returned when a blob is unknown " +
" to the registry in a specified repository. This can be returned with a standard get or " +
"if a manifest references an unknown layer during upload.",
},
BLOB_UPLOAD_INVALID: {
Message: "blob upload invalid",
Description: `The blob upload encountered an error and can no longer proceed.`,
},
BLOB_UPLOAD_UNKNOWN: {
Message: "blob upload unknown to registry",
Description: `If a blob upload has been cancelled or was never started, this error code MAY be returned.`,
},
DIGEST_INVALID: {
Message: "provided digest did not match uploaded content",
Description: "When a blob is uploaded, the registry will check that the content matches the " +
"digest provided by the client. The error MAY include a detail structure with the key " +
"\"digest\", including the invalid digest string. This error MAY also be returned when " +
"a manifest includes an invalid layer digest.",
},
MANIFEST_BLOB_UNKNOWN: {
Message: "blob unknown to registry",
Description: `This error MAY be returned when a manifest blob is unknown
to the registry.`,
},
MANIFEST_INVALID: {
Message: "manifest invalid",
Description: `During upload, manifests undergo several checks ensuring
validity. If those checks fail, this error MAY be returned, unless a more
specific error is included. The detail will contain information the failed
validation.`,
},
MANIFEST_UNKNOWN: {
Message: "manifest unknown",
Description: `This error is returned when the manifest, identified by name
and tag is unknown to the repository.`,
},
MANIFEST_UNVERIFIED: {
Message: "manifest failed signature verification",
Description: `During manifest upload, if the manifest fails signature
verification, this error will be returned.`,
},
NAME_INVALID: {
Message: "invalid repository name",
Description: `Invalid repository name encountered either during manifest
validation or any API operation.`,
},
NAME_UNKNOWN: {
Message: "repository name not known to registry",
Description: `This is returned if the name used during an operation is unknown to the registry.`,
},
SIZE_INVALID: {
Message: "provided length did not match content length",
Description: "When a layer is uploaded, the provided size will be checked against the uploaded " +
"content. If they do not match, this error will be returned.",
},
TAG_INVALID: {
Message: "manifest tag did not match URI",
Description: `During a manifest upload, if the tag in the manifest does
not match the uri tag, this error will be returned.`,
},
UNAUTHORIZED: {
Message: "authentication required",
Description: `The access controller was unable to authenticate the client.
Often this will be accompanied by a Www-Authenticate HTTP response header
indicating how to authenticate.`,
},
DENIED: {
Message: "requested access to the resource is denied",
Description: `The access controller denied access for the operation on a
resource.`,
},
UNSUPPORTED: {
Message: "The operation is unsupported.",
Description: `The operation was unsupported due to a missing
implementation or invalid set of parameters.`,
},
}
e, ok := errMap[code]
if !ok {
panic(errors.ErrUnknownCode)
}
e.Code = code
e.Detail = detail
return e
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
func NewLogger(config *Config) zerolog.Logger {
zerolog.TimeFieldFormat = time.RFC3339Nano
lvl, err := zerolog.ParseLevel(config.Log.Level)
if err != nil {
panic(err)
}
zerolog.SetGlobalLevel(lvl)
var log zerolog.Logger
if config.Log.Output == "" {
log = zerolog.New(os.Stdout)
} else {
file, err := os.OpenFile(config.Log.Output, os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
panic(err)
}
log = zerolog.New(file)
}
return log.With().Timestamp().Logger()
}
func Logger(log zerolog.Logger) gin.HandlerFunc {
l := log.With().Str("module", "http").Logger()
return func(ginCtx *gin.Context) {
// Start timer
start := time.Now()
path := ginCtx.Request.URL.Path
raw := ginCtx.Request.URL.RawQuery
// Process request
ginCtx.Next()
// Stop timer
end := time.Now()
latency := end.Sub(start)
if latency > time.Minute {
// Truncate in a golang < 1.8 safe way
latency -= latency % time.Second
}
clientIP := ginCtx.ClientIP()
method := ginCtx.Request.Method
headers := ginCtx.Request.Header
statusCode := ginCtx.Writer.Status()
errMsg := ginCtx.Errors.ByType(gin.ErrorTypePrivate).String()
bodySize := ginCtx.Writer.Size()
if raw != "" {
path = path + "?" + raw
}
l.Info().
Str("clientIP", clientIP).
Str("method", method).
Str("path", path).
Int("statusCode", statusCode).
Str("errMsg", errMsg).
Str("latency", latency.String()).
Int("bodySize", bodySize).
Interface("headers", headers).
Msg("HTTP API")
}
}
+821
View File
@@ -0,0 +1,821 @@
// @title Open Container Initiative Distribution Specification
// @version v0.1.0-dev
// @description APIs for Open Container Initiative Distribution Specification
// @contact.name API Support
// @contact.url http://www.swagger.io/support
// @contact.email support@swagger.io
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
package api
import (
"fmt"
"net/http"
"path"
"strconv"
"strings"
_ "github.com/anuvu/zot/docs" // nolint (golint) - as required by swaggo
"github.com/anuvu/zot/errors"
"github.com/gin-gonic/gin"
ispec "github.com/opencontainers/image-spec/specs-go/v1"
ginSwagger "github.com/swaggo/gin-swagger"
"github.com/swaggo/gin-swagger/swaggerFiles"
)
const RoutePrefix = "/v2"
const DistContentDigestKey = "Docker-Content-Digest"
const BlobUploadUUID = "Blob-Upload-UUID"
type RouteHandler struct {
c *Controller
}
func NewRouteHandler(c *Controller) *RouteHandler {
rh := &RouteHandler{c: c}
rh.SetupRoutes()
return rh
}
func (rh *RouteHandler) SetupRoutes() {
rh.c.Router.Use(BasicAuthHandler(rh.c))
g := rh.c.Router.Group(RoutePrefix)
{
g.GET("/", rh.CheckVersionSupport)
g.GET("/:name/tags/list", rh.ListTags)
g.HEAD("/:name/manifests/:reference", rh.CheckManifest)
g.GET("/:name/manifests/:reference", rh.GetManifest)
g.PUT("/:name/manifests/:reference", rh.UpdateManifest)
g.DELETE("/:name/manifests/:reference", rh.DeleteManifest)
g.HEAD("/:name/blobs/:digest", rh.CheckBlob)
g.GET("/:name/blobs/:digest", rh.GetBlob)
g.DELETE("/:name/blobs/:digest", rh.DeleteBlob)
// NOTE: some routes as per the spec need to be setup with URL params which
// must equal specific keywords
// route for POST "/v2/:name/blobs/uploads/" and param ":digest"="uploads"
g.POST("/:name/blobs/:digest/", rh.CreateBlobUpload)
// route for GET "/v2/:name/blobs/uploads/:uuid" and param ":digest"="uploads"
g.GET("/:name/blobs/:digest/:uuid", rh.GetBlobUpload)
// route for PATCH "/v2/:name/blobs/uploads/:uuid" and param ":digest"="uploads"
g.PATCH("/:name/blobs/:digest/:uuid", rh.PatchBlobUpload)
// route for PUT "/v2/:name/blobs/uploads/:uuid" and param ":digest"="uploads"
g.PUT("/:name/blobs/:digest/:uuid", rh.UpdateBlobUpload)
// route for DELETE "/v2/:name/blobs/uploads/:uuid" and param ":digest"="uploads"
g.DELETE("/:name/blobs/:digest/:uuid", rh.DeleteBlobUpload)
// route for GET "/v2/_catalog" and param ":name"="_catalog"
g.GET("/:name", rh.ListRepositories)
}
// swagger docs "/swagger/v2/index.html"
rh.c.Router.GET("/swagger/v2/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}
// Method handlers
// CheckVersionSupport godoc
// @Summary Check API support
// @Description Check if this API version is supported
// @Router /v2/ [get]
// @Accept json
// @Produce json
// @Success 200 {string} string "ok"
func (rh *RouteHandler) CheckVersionSupport(ginCtx *gin.Context) {
ginCtx.Data(http.StatusOK, "application/json; charset=utf-8", []byte{})
}
type ImageTags struct {
Name string `json:"name"`
Tags []string `json:"tags"`
}
// ListTags godoc
// @Summary List image tags
// @Description List all image tags in a repository
// @Router /v2/{name}/tags/list [get]
// @Accept json
// @Produce json
// @Param name path string true "test"
// @Success 200 {object} api.ImageTags
// @Failure 404 {string} string "not found"
func (rh *RouteHandler) ListTags(ginCtx *gin.Context) {
name := ginCtx.Param("name")
if name == "" {
ginCtx.Status(http.StatusNotFound)
return
}
tags, err := rh.c.ImageStore.GetImageTags(name)
if err != nil {
ginCtx.JSON(http.StatusNotFound, NewError(NAME_UNKNOWN, map[string]string{"name": name}))
return
}
ginCtx.JSON(http.StatusOK, ImageTags{Name: name, Tags: tags})
}
// CheckManifest godoc
// @Summary Check image manifest
// @Description Check an image's manifest given a reference or a digest
// @Router /v2/{name}/manifests/{reference} [head]
// @Accept json
// @Produce json
// @Param name path string true "repository name"
// @Param reference path string true "image reference or digest"
// @Success 200 {string} string "ok"
// @Header 200 {object} api.DistContentDigestKey
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "internal server error"
func (rh *RouteHandler) CheckManifest(ginCtx *gin.Context) {
name := ginCtx.Param("name")
if name == "" {
ginCtx.Status(http.StatusNotFound)
return
}
reference := ginCtx.Param("reference")
if reference == "" {
ginCtx.JSON(http.StatusNotFound, NewError(MANIFEST_INVALID, map[string]string{"reference": reference}))
return
}
_, digest, _, err := rh.c.ImageStore.GetImageManifest(name, reference)
if err != nil {
switch err {
case errors.ErrManifestNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(MANIFEST_UNKNOWN, map[string]string{"reference": reference}))
default:
ginCtx.JSON(http.StatusInternalServerError, NewError(MANIFEST_INVALID, map[string]string{"reference": reference}))
}
return
}
ginCtx.Status(http.StatusOK)
ginCtx.Header(DistContentDigestKey, digest)
ginCtx.Header("Content-Length", "0")
}
// NOTE: https://github.com/swaggo/swag/issues/387
type ImageManifest struct {
ispec.Manifest
}
// GetManifest godoc
// @Summary Get image manifest
// @Description Get an image's manifest given a reference or a digest
// @Accept json
// @Produce application/vnd.oci.image.manifest.v1+json
// @Param name path string true "repository name"
// @Param reference path string true "image reference or digest"
// @Success 200 {object} api.ImageManifest
// @Header 200 {object} api.DistContentDigestKey
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "internal server error"
// @Router /v2/{name}/manifests/{reference} [get]
func (rh *RouteHandler) GetManifest(ginCtx *gin.Context) {
name := ginCtx.Param("name")
if name == "" {
ginCtx.Status(http.StatusNotFound)
return
}
reference := ginCtx.Param("reference")
if reference == "" {
ginCtx.JSON(http.StatusNotFound, NewError(MANIFEST_UNKNOWN, map[string]string{"reference": reference}))
return
}
content, digest, mediaType, err := rh.c.ImageStore.GetImageManifest(name, reference)
if err != nil {
switch err {
case errors.ErrRepoNotFound:
case errors.ErrRepoBadVersion:
ginCtx.JSON(http.StatusNotFound, NewError(NAME_UNKNOWN, map[string]string{"name": name}))
case errors.ErrManifestNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(MANIFEST_UNKNOWN, map[string]string{"reference": reference}))
default:
ginCtx.Status(http.StatusInternalServerError)
}
return
}
ginCtx.Data(http.StatusOK, mediaType, content)
ginCtx.Header(DistContentDigestKey, digest)
}
// UpdateManifest godoc
// @Summary Update image manifest
// @Description Update an image's manifest given a reference or a digest
// @Accept json
// @Produce json
// @Param name path string true "repository name"
// @Param reference path string true "image reference or digest"
// @Header 201 {object} api.DistContentDigestKey
// @Success 201 {string} string "created"
// @Failure 400 {string} string "bad request"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "internal server error"
// @Router /v2/{name}/manifests/{reference} [put]
func (rh *RouteHandler) UpdateManifest(ginCtx *gin.Context) {
name := ginCtx.Param("name")
if name == "" {
ginCtx.Status(http.StatusNotFound)
return
}
reference := ginCtx.Param("reference")
if reference == "" {
ginCtx.JSON(http.StatusNotFound, NewError(MANIFEST_INVALID, map[string]string{"reference": reference}))
return
}
mediaType := ginCtx.ContentType()
if mediaType != ispec.MediaTypeImageManifest {
ginCtx.Status(http.StatusUnsupportedMediaType)
return
}
body, err := ginCtx.GetRawData()
if err != nil {
ginCtx.Status(http.StatusInternalServerError)
return
}
digest, err := rh.c.ImageStore.PutImageManifest(name, reference, mediaType, body)
if err != nil {
switch err {
case errors.ErrRepoNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(NAME_UNKNOWN, map[string]string{"name": name}))
case errors.ErrManifestNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(MANIFEST_UNKNOWN, map[string]string{"reference": reference}))
case errors.ErrBadManifest:
ginCtx.JSON(http.StatusBadRequest, NewError(MANIFEST_INVALID, map[string]string{"reference": reference}))
case errors.ErrBlobNotFound:
ginCtx.JSON(http.StatusBadRequest, NewError(BLOB_UNKNOWN, map[string]string{"blob": digest}))
default:
ginCtx.Status(http.StatusInternalServerError)
}
return
}
ginCtx.Status(http.StatusCreated)
ginCtx.Header("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, digest))
ginCtx.Header(DistContentDigestKey, digest)
}
// DeleteManifest godoc
// @Summary Delete image manifest
// @Description Delete an image's manifest given a reference or a digest
// @Accept json
// @Produce json
// @Param name path string true "repository name"
// @Param reference path string true "image reference or digest"
// @Success 200 {string} string "ok"
// @Router /v2/{name}/manifests/{reference} [delete]
func (rh *RouteHandler) DeleteManifest(ginCtx *gin.Context) {
name := ginCtx.Param("name")
if name == "" {
ginCtx.Status(http.StatusNotFound)
return
}
reference := ginCtx.Param("reference")
if reference == "" {
ginCtx.Status(http.StatusNotFound)
return
}
err := rh.c.ImageStore.DeleteImageManifest(name, reference)
if err != nil {
switch err {
case errors.ErrRepoNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(NAME_UNKNOWN, map[string]string{"name": name}))
case errors.ErrManifestNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(MANIFEST_UNKNOWN, map[string]string{"reference": reference}))
default:
ginCtx.Status(http.StatusInternalServerError)
}
return
}
ginCtx.Status(http.StatusOK)
}
// CheckBlob godoc
// @Summary Check image blob/layer
// @Description Check an image's blob/layer given a digest
// @Accept json
// @Produce json
// @Param name path string true "repository name"
// @Param digest path string true "blob/layer digest"
// @Success 200 {object} api.ImageManifest
// @Header 200 {object} api.DistContentDigestKey
// @Router /v2/{name}/blobs/{digest} [head]
func (rh *RouteHandler) CheckBlob(ginCtx *gin.Context) {
name := ginCtx.Param("name")
if name == "" {
ginCtx.Status(http.StatusNotFound)
return
}
digest := ginCtx.Param("digest")
if digest == "" {
ginCtx.Status(http.StatusNotFound)
return
}
mediaType := ginCtx.Request.Header.Get("Accept")
ok, blen, err := rh.c.ImageStore.CheckBlob(name, digest, mediaType)
if err != nil {
switch err {
case errors.ErrBadBlobDigest:
ginCtx.JSON(http.StatusBadRequest, NewError(DIGEST_INVALID, map[string]string{"digest": digest}))
case errors.ErrRepoNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(NAME_UNKNOWN, map[string]string{"name": name}))
case errors.ErrBlobNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(BLOB_UNKNOWN, map[string]string{"digest": digest}))
default:
ginCtx.Status(http.StatusInternalServerError)
}
return
}
if !ok {
ginCtx.JSON(http.StatusNotFound, NewError(BLOB_UNKNOWN, map[string]string{"digest": digest}))
return
}
ginCtx.Status(http.StatusOK)
ginCtx.Header("Content-Length", fmt.Sprintf("%d", blen))
ginCtx.Header(DistContentDigestKey, digest)
}
// GetBlob godoc
// @Summary Get image blob/layer
// @Description Get an image's blob/layer given a digest
// @Accept json
// @Produce application/vnd.oci.image.layer.v1.tar+gzip
// @Param name path string true "repository name"
// @Param digest path string true "blob/layer digest"
// @Header 200 {object} api.DistContentDigestKey
// @Success 200 {object} api.ImageManifest
// @Router /v2/{name}/blobs/{digest} [get]
func (rh *RouteHandler) GetBlob(ginCtx *gin.Context) {
name := ginCtx.Param("name")
if name == "" {
ginCtx.Status(http.StatusNotFound)
return
}
digest := ginCtx.Param("digest")
if digest == "" {
ginCtx.Status(http.StatusNotFound)
return
}
mediaType := ginCtx.Request.Header.Get("Accept")
br, blen, err := rh.c.ImageStore.GetBlob(name, digest, mediaType)
if err != nil {
switch err {
case errors.ErrBadBlobDigest:
ginCtx.JSON(http.StatusBadRequest, NewError(DIGEST_INVALID, map[string]string{"digest": digest}))
case errors.ErrRepoNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(NAME_UNKNOWN, map[string]string{"name": name}))
case errors.ErrBlobNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(BLOB_UNKNOWN, map[string]string{"digest": digest}))
default:
ginCtx.Status(http.StatusInternalServerError)
}
return
}
ginCtx.Status(http.StatusOK)
ginCtx.Header("Content-Length", fmt.Sprintf("%d", blen))
ginCtx.Header(DistContentDigestKey, digest)
// return the blob data
ginCtx.DataFromReader(http.StatusOK, blen, mediaType, br, map[string]string{})
}
// DeleteBlob godoc
// @Summary Delete image blob/layer
// @Description Delete an image's blob/layer given a digest
// @Accept json
// @Produce json
// @Param name path string true "repository name"
// @Param digest path string true "blob/layer digest"
// @Success 202 {string} string "accepted"
// @Router /v2/{name}/blobs/{digest} [delete]
func (rh *RouteHandler) DeleteBlob(ginCtx *gin.Context) {
name := ginCtx.Param("name")
if name == "" {
ginCtx.Status(http.StatusNotFound)
return
}
digest := ginCtx.Param("digest")
if digest == "" {
ginCtx.Status(http.StatusNotFound)
return
}
err := rh.c.ImageStore.DeleteBlob(name, digest)
if err != nil {
switch err {
case errors.ErrBadBlobDigest:
ginCtx.JSON(http.StatusBadRequest, NewError(DIGEST_INVALID, map[string]string{"digest": digest}))
case errors.ErrRepoNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(NAME_UNKNOWN, map[string]string{"name": name}))
case errors.ErrBlobNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(BLOB_UNKNOWN, map[string]string{"digest": digest}))
default:
ginCtx.Status(http.StatusInternalServerError)
}
return
}
ginCtx.Status(http.StatusAccepted)
}
// CreateBlobUpload godoc
// @Summary Create image blob/layer upload
// @Description Create a new image blob/layer upload
// @Accept json
// @Produce json
// @Param name path string true "repository name"
// @Success 202 {string} string "accepted"
// @Header 202 {string} Location "/v2/{name}/blobs/uploads/{uuid}"
// @Header 202 {string} Range "bytes=0-0"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "internal server error"
// @Router /v2/{name}/blobs/uploads [post]
func (rh *RouteHandler) CreateBlobUpload(ginCtx *gin.Context) {
if paramIsNot(ginCtx, "digest", "uploads") {
ginCtx.Status(http.StatusNotFound)
return
}
name := ginCtx.Param("name")
if name == "" {
ginCtx.Status(http.StatusNotFound)
return
}
u, err := rh.c.ImageStore.NewBlobUpload(name)
if err != nil {
switch err {
case errors.ErrRepoNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(NAME_UNKNOWN, map[string]string{"name": name}))
default:
ginCtx.Status(http.StatusInternalServerError)
}
return
}
ginCtx.Status(http.StatusAccepted)
ginCtx.Header("Location", path.Join(ginCtx.Request.URL.String(), u))
ginCtx.Header("Range", "bytes=0-0")
}
// GetBlobUpload godoc
// @Summary Get image blob/layer upload
// @Description Get an image's blob/layer upload given a uuid
// @Accept json
// @Produce json
// @Param name path string true "repository name"
// @Param uuid path string true "upload uuid"
// @Success 204 {string} string "no content"
// @Header 202 {string} Location "/v2/{name}/blobs/uploads/{uuid}"
// @Header 202 {string} Range "bytes=0-128"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "internal server error"
// @Router /v2/{name}/blobs/uploads/{uuid} [get]
func (rh *RouteHandler) GetBlobUpload(ginCtx *gin.Context) {
if paramIsNot(ginCtx, "digest", "uploads") {
ginCtx.Status(http.StatusNotFound)
return
}
name := ginCtx.Param("name")
if name == "" {
ginCtx.Status(http.StatusNotFound)
return
}
uuid := ginCtx.Param("uuid")
if uuid == "" {
ginCtx.Status(http.StatusNotFound)
return
}
size, err := rh.c.ImageStore.GetBlobUpload(name, uuid)
if err != nil {
switch err {
case errors.ErrBadUploadRange:
case errors.ErrBadBlobDigest:
ginCtx.JSON(http.StatusBadRequest, NewError(BLOB_UPLOAD_INVALID, map[string]string{"uuid": uuid}))
case errors.ErrRepoNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(NAME_UNKNOWN, map[string]string{"name": name}))
case errors.ErrUploadNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(BLOB_UPLOAD_UNKNOWN, map[string]string{"uuid": uuid}))
default:
ginCtx.Status(http.StatusInternalServerError)
}
return
}
ginCtx.Status(http.StatusNoContent)
ginCtx.Header("Location", path.Join(ginCtx.Request.URL.String(), uuid))
ginCtx.Header("Range", fmt.Sprintf("bytes=0-%d", size))
}
// PatchBlobUpload godoc
// @Summary Resume image blob/layer upload
// @Description Resume an image's blob/layer upload given an uuid
// @Accept json
// @Produce json
// @Param name path string true "repository name"
// @Param uuid path string true "upload uuid"
// @Success 202 {string} string "accepted"
// @Header 202 {string} Location "/v2/{name}/blobs/uploads/{uuid}"
// @Header 202 {string} Range "bytes=0-128"
// @Header 200 {object} api.BlobUploadUUID
// @Failure 400 {string} string "bad request"
// @Failure 404 {string} string "not found"
// @Failure 416 {string} string "range not satisfiable"
// @Failure 500 {string} string "internal server error"
// @Router /v2/{name}/blobs/uploads/{uuid} [patch]
func (rh *RouteHandler) PatchBlobUpload(ginCtx *gin.Context) {
rh.c.Log.Info().Interface("headers", ginCtx.Request.Header).Msg("request headers")
if paramIsNot(ginCtx, "digest", "uploads") {
ginCtx.Status(http.StatusNotFound)
return
}
name := ginCtx.Param("name")
if name == "" {
ginCtx.Status(http.StatusNotFound)
return
}
uuid := ginCtx.Param("uuid")
if uuid == "" {
ginCtx.Status(http.StatusNotFound)
return
}
var err error
var contentLength int64
if contentLength, err = strconv.ParseInt(ginCtx.Request.Header.Get("Content-Length"), 10, 64); err != nil {
rh.c.Log.Warn().Str("actual", ginCtx.Request.Header.Get("Content-Length")).Msg("invalid content length")
ginCtx.Status(http.StatusBadRequest)
return
}
contentRange := ginCtx.Request.Header.Get("Content-Range")
if contentRange == "" {
rh.c.Log.Warn().Str("actual", ginCtx.Request.Header.Get("Content-Range")).Msg("invalid content range")
ginCtx.Status(http.StatusRequestedRangeNotSatisfiable)
return
}
var from, to int64
if from, to, err = getContentRange(ginCtx); err != nil || (to-from) != contentLength {
ginCtx.Status(http.StatusRequestedRangeNotSatisfiable)
return
}
if ginCtx.ContentType() != "application/octet-stream" {
rh.c.Log.Warn().Str("actual", ginCtx.ContentType()).Msg("invalid media type")
ginCtx.Status(http.StatusUnsupportedMediaType)
return
}
clen, err := rh.c.ImageStore.PutBlobChunk(name, uuid, from, to, ginCtx.Request.Body)
if err != nil {
switch err {
case errors.ErrBadUploadRange:
ginCtx.JSON(http.StatusBadRequest, NewError(BLOB_UPLOAD_INVALID, map[string]string{"uuid": uuid}))
case errors.ErrRepoNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(NAME_UNKNOWN, map[string]string{"name": name}))
case errors.ErrUploadNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(BLOB_UPLOAD_UNKNOWN, map[string]string{"uuid": uuid}))
default:
ginCtx.Status(http.StatusInternalServerError)
}
return
}
ginCtx.Status(http.StatusAccepted)
ginCtx.Header("Location", path.Join(ginCtx.Request.URL.String(), uuid))
ginCtx.Header("Range", fmt.Sprintf("bytes=0-%d", clen))
ginCtx.Header("Content-Length", "0")
ginCtx.Header(BlobUploadUUID, uuid)
}
// UpdateBlobUpload godoc
// @Summary Update image blob/layer upload
// @Description Update and finish an image's blob/layer upload given a digest
// @Accept json
// @Produce json
// @Param name path string true "repository name"
// @Param uuid path string true "upload uuid"
// @Param digest query string true "blob/layer digest"
// @Success 201 {string} string "created"
// @Header 202 {string} Location "/v2/{name}/blobs/{digest}"
// @Header 200 {object} api.DistContentDigestKey
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "internal server error"
// @Router /v2/{name}/blobs/uploads/{uuid} [put]
func (rh *RouteHandler) UpdateBlobUpload(ginCtx *gin.Context) {
if paramIsNot(ginCtx, "digest", "uploads") {
ginCtx.Status(http.StatusNotFound)
return
}
name := ginCtx.Param("name")
if name == "" {
ginCtx.Status(http.StatusNotFound)
return
}
uuid := ginCtx.Param("uuid")
if uuid == "" {
ginCtx.Status(http.StatusNotFound)
return
}
digest := ginCtx.Query("digest")
if digest == "" {
ginCtx.Status(http.StatusBadRequest)
return
}
contentPresent := true
contentLen, err := strconv.ParseInt(ginCtx.Request.Header.Get("Content-Length"), 10, 64)
if err != nil || contentLen == 0 {
contentPresent = false
}
contentRangePresent := true
if ginCtx.Request.Header.Get("Content-Range") == "" {
contentRangePresent = false
}
// we expect at least one of "Content-Length" or "Content-Range" to be
// present
if !contentPresent && !contentRangePresent {
ginCtx.Status(http.StatusBadRequest)
return
}
var from, to int64
if contentPresent {
if ginCtx.ContentType() != "application/octet-stream" {
ginCtx.Status(http.StatusUnsupportedMediaType)
return
}
contentRange := ginCtx.Request.Header.Get("Content-Range")
if contentRange == "" { // monolithic upload
from = 0
if contentLen == 0 {
ginCtx.Status(http.StatusBadRequest)
return
}
to = contentLen
} else if from, to, err = getContentRange(ginCtx); err != nil { // finish chunked upload
ginCtx.Status(http.StatusRequestedRangeNotSatisfiable)
return
}
_, err = rh.c.ImageStore.PutBlobChunk(name, uuid, from, to, ginCtx.Request.Body)
if err != nil {
switch err {
case errors.ErrBadUploadRange:
ginCtx.JSON(http.StatusBadRequest, NewError(BLOB_UPLOAD_INVALID, map[string]string{"uuid": uuid}))
case errors.ErrRepoNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(NAME_UNKNOWN, map[string]string{"name": name}))
case errors.ErrUploadNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(BLOB_UPLOAD_UNKNOWN, map[string]string{"uuid": uuid}))
default:
ginCtx.Status(http.StatusInternalServerError)
}
return
}
}
// blob chunks already transferred, just finish
if err := rh.c.ImageStore.FinishBlobUpload(name, uuid, ginCtx.Request.Body, digest); err != nil {
switch err {
case errors.ErrBadBlobDigest:
ginCtx.JSON(http.StatusBadRequest, NewError(DIGEST_INVALID, map[string]string{"digest": digest}))
case errors.ErrBadUploadRange:
ginCtx.JSON(http.StatusBadRequest, NewError(BLOB_UPLOAD_INVALID, map[string]string{"uuid": uuid}))
case errors.ErrRepoNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(NAME_UNKNOWN, map[string]string{"name": name}))
case errors.ErrUploadNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(BLOB_UPLOAD_UNKNOWN, map[string]string{"uuid": uuid}))
default:
ginCtx.Status(http.StatusInternalServerError)
}
return
}
ginCtx.Status(http.StatusCreated)
ginCtx.Header("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
ginCtx.Header("Content-Length", "0")
ginCtx.Header(DistContentDigestKey, digest)
}
// DeleteBlobUpload godoc
// @Summary Delete image blob/layer
// @Description Delete an image's blob/layer given a digest
// @Accept json
// @Produce json
// @Param name path string true "repository name"
// @Param uuid path string true "upload uuid"
// @Success 200 {string} string "ok"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "internal server error"
// @Router /v2/{name}/blobs/uploads/{uuid} [delete]
func (rh *RouteHandler) DeleteBlobUpload(ginCtx *gin.Context) {
if paramIsNot(ginCtx, "digest", "uploads") {
ginCtx.Status(http.StatusNotFound)
return
}
name := ginCtx.Param("name")
uuid := ginCtx.Param("uuid")
if err := rh.c.ImageStore.DeleteBlobUpload(name, uuid); err != nil {
switch err {
case errors.ErrRepoNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(NAME_UNKNOWN, map[string]string{"name": name}))
case errors.ErrUploadNotFound:
ginCtx.JSON(http.StatusNotFound, NewError(BLOB_UPLOAD_UNKNOWN, map[string]string{"uuid": uuid}))
default:
ginCtx.Status(http.StatusInternalServerError)
}
return
}
ginCtx.Status(http.StatusOK)
}
type RepositoryList struct {
Repositories []string `json:"repositories"`
}
// ListRepositories godoc
// @Summary List image repositories
// @Description List all image repositories
// @Accept json
// @Produce json
// @Success 200 {object} api.RepositoryList
// @Failure 500 {string} string "internal server error"
// @Router /v2/_catalog [get]
func (rh *RouteHandler) ListRepositories(ginCtx *gin.Context) {
if paramIsNot(ginCtx, "name", "_catalog") {
ginCtx.Status(http.StatusNotFound)
return
}
repos, err := rh.c.ImageStore.GetRepositories()
if err != nil {
ginCtx.Status(http.StatusInternalServerError)
return
}
is := RepositoryList{Repositories: repos}
ginCtx.JSON(http.StatusOK, is)
}
// helper routines
func paramIsNot(ginCtx *gin.Context, name string, expected string) bool {
actual := ginCtx.Param(name)
return actual != expected
}
func getContentRange(ginCtx *gin.Context) (int64 /* from */, int64 /* to */, error) {
contentRange := ginCtx.Request.Header.Get("Content-Range")
tokens := strings.Split(contentRange, "-")
from, err := strconv.ParseInt(tokens[0], 10, 64)
if err != nil {
return -1, -1, errors.ErrBadUploadRange
}
to, err := strconv.ParseInt(tokens[1], 10, 64)
if err != nil {
return -1, -1, errors.ErrBadUploadRange
}
if from > to {
return -1, -1, errors.ErrBadUploadRange
}
return from, to, nil
}
+333
View File
@@ -0,0 +1,333 @@
package api_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"testing"
"time"
"github.com/anuvu/zot/pkg/api"
godigest "github.com/opencontainers/go-digest"
ispec "github.com/opencontainers/image-spec/specs-go/v1"
. "github.com/smartystreets/goconvey/convey"
"gopkg.in/resty.v1"
)
const (
DefaultContentType = "application/json; charset=utf-8"
BaseURL = "http://127.0.0.1:8080"
)
func TestAPI(t *testing.T) {
Convey("Make API calls to the controller", t, func(c C) {
Convey("check version", func() {
resp, err := resty.R().Get(BaseURL + "/v2/")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
})
Convey("Get repository catalog", func() {
resp, err := resty.R().Get(BaseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
So(resp.String(), ShouldNotBeEmpty)
So(resp.Header().Get("Content-Type"), ShouldEqual, DefaultContentType)
var repoList api.RepositoryList
err = json.Unmarshal(resp.Body(), &repoList)
So(err, ShouldBeNil)
So(len(repoList.Repositories), ShouldEqual, 0)
})
Convey("Get images in a repository", func() {
// non-existent repository should fail
resp, err := resty.R().Get(BaseURL + "/v2/repo/tags/list")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 404)
So(resp.String(), ShouldNotBeEmpty)
// after newly created upload should fail
resp, err = resty.R().Post(BaseURL + "/v2/repo/blobs/uploads/")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 202)
resp, err = resty.R().Get(BaseURL + "/v2/repo/tags/list")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
So(resp.String(), ShouldNotBeEmpty)
})
Convey("Monolithic blob upload", func() {
resp, err := resty.R().Post(BaseURL + "/v2/repo/blobs/uploads/")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 202)
loc := resp.Header().Get("Location")
So(loc, ShouldNotBeEmpty)
resp, err = resty.R().Get(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 204)
resp, err = resty.R().Get(BaseURL + "/v2/repo/tags/list")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
So(resp.String(), ShouldNotBeEmpty)
// without a "?digest=<>" should fail
content := []byte("this is a blob")
digest := godigest.FromBytes(content)
So(digest, ShouldNotBeNil)
resp, err = resty.R().Put(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 400)
// without the Content-Length should fail
resp, err = resty.R().SetQueryParam("digest", digest.String()).Put(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 400)
// without any data to send, should fail
resp, err = resty.R().SetQueryParam("digest", digest.String()).
SetHeader("Content-Type", "application/octet-stream").Put(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 400)
// monolithic blob upload: success
resp, err = resty.R().SetQueryParam("digest", digest.String()).
SetHeader("Content-Type", "application/octet-stream").SetBody(content).Put(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 201)
blobLoc := resp.Header().Get("Location")
So(blobLoc, ShouldNotBeEmpty)
So(resp.Header().Get("Content-Length"), ShouldEqual, "0")
So(resp.Header().Get(api.DistContentDigestKey), ShouldNotBeEmpty)
// upload reference should now be removed
resp, err = resty.R().Get(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 404)
// blob reference should be accessible
resp, err = resty.R().Get(BaseURL + blobLoc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
})
Convey("Chunked blob upload", func() {
resp, err := resty.R().Post(BaseURL + "/v2/repo/blobs/uploads/")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 202)
loc := resp.Header().Get("Location")
So(loc, ShouldNotBeEmpty)
var buf bytes.Buffer
chunk1 := []byte("this is the first chunk")
n, err := buf.Write(chunk1)
So(n, ShouldEqual, len(chunk1))
So(err, ShouldBeNil)
// write first chunk
contentRange := fmt.Sprintf("%d-%d", 0, len(chunk1))
resp, err = resty.R().SetHeader("Content-Type", "application/octet-stream").
SetHeader("Content-Range", contentRange).SetBody(chunk1).Patch(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 202)
// check progress
resp, err = resty.R().Get(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 204)
r := resp.Header().Get("Range")
So(r, ShouldNotBeEmpty)
So(r, ShouldEqual, "bytes="+contentRange)
// write same chunk should fail
contentRange = fmt.Sprintf("%d-%d", 0, len(chunk1))
resp, err = resty.R().SetHeader("Content-Type", "application/octet-stream").
SetHeader("Content-Range", contentRange).SetBody(chunk1).Patch(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 400)
So(resp.String(), ShouldNotBeEmpty)
chunk2 := []byte("this is the second chunk")
n, err = buf.Write(chunk2)
So(n, ShouldEqual, len(chunk2))
So(err, ShouldBeNil)
digest := godigest.FromBytes(buf.Bytes())
So(digest, ShouldNotBeNil)
// write final chunk
contentRange = fmt.Sprintf("%d-%d", len(chunk1), len(buf.Bytes()))
resp, err = resty.R().SetQueryParam("digest", digest.String()).
SetHeader("Content-Range", contentRange).
SetHeader("Content-Type", "application/octet-stream").SetBody(chunk2).Put(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 201)
blobLoc := resp.Header().Get("Location")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 201)
So(blobLoc, ShouldNotBeEmpty)
So(resp.Header().Get("Content-Length"), ShouldEqual, "0")
So(resp.Header().Get(api.DistContentDigestKey), ShouldNotBeEmpty)
// upload reference should now be removed
resp, err = resty.R().Get(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 404)
// blob reference should be accessible
resp, err = resty.R().Get(BaseURL + blobLoc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
})
Convey("Create and delete uploads", func() {
// create a upload
resp, err := resty.R().Post(BaseURL + "/v2/repo/blobs/uploads/")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 202)
loc := resp.Header().Get("Location")
So(loc, ShouldNotBeEmpty)
// delete this upload
resp, err = resty.R().Delete(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
})
Convey("Create and delete blobs", func() {
// create a upload
resp, err := resty.R().Post(BaseURL + "/v2/repo/blobs/uploads/")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 202)
loc := resp.Header().Get("Location")
So(loc, ShouldNotBeEmpty)
content := []byte("this is a blob")
digest := godigest.FromBytes(content)
So(digest, ShouldNotBeNil)
// monolithic blob upload
resp, err = resty.R().SetQueryParam("digest", digest.String()).
SetHeader("Content-Type", "application/octet-stream").SetBody(content).Put(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 201)
blobLoc := resp.Header().Get("Location")
So(blobLoc, ShouldNotBeEmpty)
So(resp.Header().Get(api.DistContentDigestKey), ShouldNotBeEmpty)
// delete this blob
resp, err = resty.R().Delete(BaseURL + blobLoc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 202)
So(resp.Header().Get("Content-Length"), ShouldEqual, "0")
})
Convey("Manifests", func() {
// create a blob/layer
resp, err := resty.R().Post(BaseURL + "/v2/repo/blobs/uploads/")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 202)
loc := resp.Header().Get("Location")
So(loc, ShouldNotBeEmpty)
resp, err = resty.R().Get(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 204)
content := []byte("this is a blob")
digest := godigest.FromBytes(content)
So(digest, ShouldNotBeNil)
// monolithic blob upload: success
resp, err = resty.R().SetQueryParam("digest", digest.String()).
SetHeader("Content-Type", "application/octet-stream").SetBody(content).Put(BaseURL + loc)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 201)
blobLoc := resp.Header().Get("Location")
So(blobLoc, ShouldNotBeEmpty)
So(resp.Header().Get("Content-Length"), ShouldEqual, "0")
So(resp.Header().Get(api.DistContentDigestKey), ShouldNotBeEmpty)
// create a manifest
m := ispec.Manifest{Layers: []ispec.Descriptor{{Digest: digest}}}
content, err = json.Marshal(m)
So(err, ShouldBeNil)
digest = godigest.FromBytes(content)
So(digest, ShouldNotBeNil)
resp, err = resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json").
SetBody(content).Put(BaseURL + "/v2/repo/manifests/test:1.0")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 201)
d := resp.Header().Get(api.DistContentDigestKey)
So(d, ShouldNotBeEmpty)
So(d, ShouldEqual, digest.String())
// check/get by tag
resp, err = resty.R().Head(BaseURL + "/v2/repo/manifests/test:1.0")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
resp, err = resty.R().Get(BaseURL + "/v2/repo/manifests/test:1.0")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
So(resp.Body(), ShouldNotBeEmpty)
// check/get by reference
resp, err = resty.R().Head(BaseURL + "/v2/repo/manifests/" + digest.String())
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
resp, err = resty.R().Get(BaseURL + "/v2/repo/manifests/" + digest.String())
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
So(resp.Body(), ShouldNotBeEmpty)
// delete manifest
resp, err = resty.R().Delete(BaseURL + "/v2/repo/manifests/test:1.0")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
// delete again should fail
resp, err = resty.R().Delete(BaseURL + "/v2/repo/manifests/" + digest.String())
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 404)
// check/get by tag
resp, err = resty.R().Head(BaseURL + "/v2/repo/manifests/test:1.0")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 404)
resp, err = resty.R().Get(BaseURL + "/v2/repo/manifests/test:1.0")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 404)
So(resp.Body(), ShouldNotBeEmpty)
// check/get by reference
resp, err = resty.R().Head(BaseURL + "/v2/repo/manifests/" + digest.String())
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 404)
resp, err = resty.R().Get(BaseURL + "/v2/repo/manifests/" + digest.String())
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, 404)
So(resp.Body(), ShouldNotBeEmpty)
})
})
}
func TestMain(m *testing.M) {
config := api.NewConfig()
c := api.NewController(config)
dir, err := ioutil.TempDir("", "oci-repo-test")
if err != nil {
panic(err)
}
//defer os.RemoveAll(dir)
c.Config.Storage.RootDirectory = dir
go func() {
// this blocks
if err := c.Run(); err != nil {
return
}
}()
for {
// poll until ready
resp, _ := resty.R().Get(BaseURL)
if resp.StatusCode() == 404 {
break
}
time.Sleep(100 * time.Millisecond)
}
status := m.Run()
ctx := context.Background()
_ = c.Server.Shutdown(ctx)
os.Exit(status)
}