mirror of
https://github.com/project-zot/zot.git
synced 2026-06-16 20:38:08 +08:00
zot: initial commit
This commit is contained in:
@@ -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",
|
||||
],
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user