From 9d4e8b459426feec812400c03540f5415ce84ff5 Mon Sep 17 00:00:00 2001 From: Ramkumar Chinchani Date: Thu, 20 Jun 2019 16:36:40 -0700 Subject: [PATCH] zot: initial commit --- .bazel/buildozer_commands.txt | 3 + .bazel/code-generator/boilerplate.go.txt | 1 + .bazel/golangcilint.yaml | 10 + .bazel/nogo-config.json | 7 + .bazel/print-workspace-status.sh | 20 + .bazelignore | 4 + .bazelrc | 7 + .gitignore | 3 + .travis.yml | 14 + BUILD.bazel | 100 +++ Makefile | 36 + Makefile.bazel | 58 ++ README.md | 12 +- WORKSPACE | 832 +++++++++++++++++++++++ cmd/zot/BUILD.bazel | 28 + cmd/zot/main.go | 13 + cmd/zot/main_test.go | 22 + docs/BUILD.bazel | 12 + docs/docs.go | 749 ++++++++++++++++++++ docs/swagger.json | 705 +++++++++++++++++++ docs/swagger.yaml | 474 +++++++++++++ errors/BUILD.bazel | 8 + errors/errors.go | 18 + examples/config-example.json | 25 + examples/config-example.yaml | 19 + examples/config-test.json | 13 + go.mod | 22 + go.sum | 242 +++++++ pkg/api/BUILD.bazel | 47 ++ pkg/api/auth.go | 91 +++ pkg/api/config.go | 52 ++ pkg/api/controller.go | 69 ++ pkg/api/controller_test.go | 237 +++++++ pkg/api/errors.go | 142 ++++ pkg/api/log.go | 70 ++ pkg/api/routes.go | 821 ++++++++++++++++++++++ pkg/api/routes_test.go | 333 +++++++++ pkg/cli/BUILD.bazel | 27 + pkg/cli/root.go | 99 +++ pkg/cli/root_test.go | 42 ++ pkg/storage/BUILD.bazel | 27 + pkg/storage/storage.go | 704 +++++++++++++++++++ pkg/storage/storage_test.go | 48 ++ test/data/ca.crt | 18 + test/data/ca.key | 28 + test/data/ca.srl | 1 + test/data/client.crt | 17 + test/data/client.csr | 15 + test/data/client.key | 28 + test/data/gen_certs.sh | 45 ++ test/data/htpasswd | 1 + test/data/server.crt | 17 + test/data/server.csr | 15 + test/data/server.key | 28 + zot.go | 1 + 55 files changed, 6478 insertions(+), 2 deletions(-) create mode 100644 .bazel/buildozer_commands.txt create mode 100644 .bazel/code-generator/boilerplate.go.txt create mode 100644 .bazel/golangcilint.yaml create mode 100644 .bazel/nogo-config.json create mode 100755 .bazel/print-workspace-status.sh create mode 100644 .bazelignore create mode 100644 .bazelrc create mode 100644 .travis.yml create mode 100644 BUILD.bazel create mode 100644 Makefile create mode 100644 Makefile.bazel create mode 100644 WORKSPACE create mode 100644 cmd/zot/BUILD.bazel create mode 100644 cmd/zot/main.go create mode 100644 cmd/zot/main_test.go create mode 100644 docs/BUILD.bazel create mode 100644 docs/docs.go create mode 100644 docs/swagger.json create mode 100644 docs/swagger.yaml create mode 100644 errors/BUILD.bazel create mode 100644 errors/errors.go create mode 100644 examples/config-example.json create mode 100644 examples/config-example.yaml create mode 100644 examples/config-test.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/api/BUILD.bazel create mode 100644 pkg/api/auth.go create mode 100644 pkg/api/config.go create mode 100644 pkg/api/controller.go create mode 100644 pkg/api/controller_test.go create mode 100644 pkg/api/errors.go create mode 100644 pkg/api/log.go create mode 100644 pkg/api/routes.go create mode 100644 pkg/api/routes_test.go create mode 100644 pkg/cli/BUILD.bazel create mode 100644 pkg/cli/root.go create mode 100644 pkg/cli/root_test.go create mode 100644 pkg/storage/BUILD.bazel create mode 100644 pkg/storage/storage.go create mode 100644 pkg/storage/storage_test.go create mode 100644 test/data/ca.crt create mode 100644 test/data/ca.key create mode 100644 test/data/ca.srl create mode 100644 test/data/client.crt create mode 100644 test/data/client.csr create mode 100644 test/data/client.key create mode 100755 test/data/gen_certs.sh create mode 100644 test/data/htpasswd create mode 100644 test/data/server.crt create mode 100644 test/data/server.csr create mode 100644 test/data/server.key create mode 100644 zot.go diff --git a/.bazel/buildozer_commands.txt b/.bazel/buildozer_commands.txt new file mode 100644 index 00000000..7ef85218 --- /dev/null +++ b/.bazel/buildozer_commands.txt @@ -0,0 +1,3 @@ +set race "on"|//...:%go_test +fix unusedLoads|//...:__pkg__ +set timeout short|//...:%go_test diff --git a/.bazel/code-generator/boilerplate.go.txt b/.bazel/code-generator/boilerplate.go.txt new file mode 100644 index 00000000..309c36fd --- /dev/null +++ b/.bazel/code-generator/boilerplate.go.txt @@ -0,0 +1 @@ +// Generated file, do not modify manually! diff --git a/.bazel/golangcilint.yaml b/.bazel/golangcilint.yaml new file mode 100644 index 00000000..2b2181d4 --- /dev/null +++ b/.bazel/golangcilint.yaml @@ -0,0 +1,10 @@ +run: + deadline: 60m + skip-dirs: + - "internal" + +linters: + enable-all: true + +output: + format: colored-line-number diff --git a/.bazel/nogo-config.json b/.bazel/nogo-config.json new file mode 100644 index 00000000..b8ad15c2 --- /dev/null +++ b/.bazel/nogo-config.json @@ -0,0 +1,7 @@ +{ + "printf": { + "exclude_files": { + "/vendor/": "no need to vet third party code" + } + } +} \ No newline at end of file diff --git a/.bazel/print-workspace-status.sh b/.bazel/print-workspace-status.sh new file mode 100755 index 00000000..1e8f99af --- /dev/null +++ b/.bazel/print-workspace-status.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# This command is used by bazel as the workspace_status_command +# to implement build stamping with git information. + +set -o errexit +set -o nounset +set -o pipefail + +GIT_COMMIT=$(git rev-parse --short HEAD) +GIT_TAG=$(git describe --abbrev=0 --tags 2>/dev/null || echo "0.0.0") + +# Prefix with STABLE_ so that these values are saved to stable-status.txt +# instead of volatile-status.txt. +# Stamped rules will be retriggered by changes to stable-status.txt, but not by +# changes to volatile-status.txt. +cat < 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") + } +} diff --git a/pkg/api/routes.go b/pkg/api/routes.go new file mode 100644 index 00000000..93660f61 --- /dev/null +++ b/pkg/api/routes.go @@ -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 +} diff --git a/pkg/api/routes_test.go b/pkg/api/routes_test.go new file mode 100644 index 00000000..acf18dbf --- /dev/null +++ b/pkg/api/routes_test.go @@ -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) +} diff --git a/pkg/cli/BUILD.bazel b/pkg/cli/BUILD.bazel new file mode 100644 index 00000000..4bc2b3a9 --- /dev/null +++ b/pkg/cli/BUILD.bazel @@ -0,0 +1,27 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["root.go"], + importpath = "github.com/anuvu/zot/pkg/cli", + visibility = ["//visibility:public"], + deps = [ + "//errors:go_default_library", + "//pkg/api:go_default_library", + "//pkg/storage:go_default_library", + "@com_github_mitchellh_mapstructure//:go_default_library", + "@com_github_opencontainers_distribution_spec//:go_default_library", + "@com_github_rs_zerolog//log:go_default_library", + "@com_github_spf13_cobra//:go_default_library", + "@com_github_spf13_viper//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + timeout = "short", + srcs = ["root_test.go"], + embed = [":go_default_library"], + race = "on", + deps = ["@com_github_smartystreets_goconvey//convey:go_default_library"], +) diff --git a/pkg/cli/root.go b/pkg/cli/root.go new file mode 100644 index 00000000..f871d173 --- /dev/null +++ b/pkg/cli/root.go @@ -0,0 +1,99 @@ +package cli + +import ( + "github.com/anuvu/zot/errors" + "github.com/anuvu/zot/pkg/api" + "github.com/anuvu/zot/pkg/storage" + "github.com/mitchellh/mapstructure" + dspec "github.com/opencontainers/distribution-spec" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// metadataConfig reports metadata after parsing, which we use to track +// errors +func metadataConfig(md *mapstructure.Metadata) viper.DecoderConfigOption { + return func(c *mapstructure.DecoderConfig) { + c.Metadata = md + } +} + +func NewRootCmd() *cobra.Command { + showVersion := false + config := api.NewConfig() + + serveCmd := &cobra.Command{ + Use: "serve ", + Aliases: []string{"serve"}, + Short: "`serve` stores and distributes OCI images", + Long: "`serve` stores and distributes OCI images", + Run: func(cmd *cobra.Command, args []string) { + if len(args) > 0 { + viper.SetConfigFile(args[0]) + if err := viper.ReadInConfig(); err != nil { + panic(err) + } + + md := &mapstructure.Metadata{} + if err := viper.Unmarshal(&config, metadataConfig(md)); err != nil { + panic(err) + } + + // if haven't found a single key or there were unused keys, report it as + // a error + if len(md.Keys) == 0 || len(md.Unused) > 0 { + panic(errors.ErrBadConfig) + } + } + c := api.NewController(config) + if err := c.Run(); err != nil { + panic(err) + } + }, + } + + gcDelUntagged := false + gcDryRun := false + + gcCmd := &cobra.Command{ + Use: "garbage-collect ", + Aliases: []string{"gc"}, + Short: "`garbage-collect` deletes layers not referenced by any manifests", + Long: "`garbage-collect` deletes layers not referenced by any manifests", + Run: func(cmd *cobra.Command, args []string) { + log.Info().Interface("values", config).Msg("configuration settings") + if config.Storage.RootDirectory != "" { + if err := storage.Scrub(config.Storage.RootDirectory, gcDryRun); err != nil { + panic(err) + } + } + }, + } + + gcCmd.Flags().StringVarP(&config.Storage.RootDirectory, "storage-root-dir", "r", "", + "Use specified directory for filestore backing image data") + _ = gcCmd.MarkFlagRequired("storage-root-dir") + gcCmd.Flags().BoolVarP(&gcDelUntagged, "delete-untagged", "m", false, + "delete manifests that are not currently referenced via tag") + gcCmd.Flags().BoolVarP(&gcDryRun, "dry-run", "d", false, + "do everything except remove the blobs") + + rootCmd := &cobra.Command{ + Use: "zot", + Short: "`zot`", + Long: "`zot`", + Run: func(cmd *cobra.Command, args []string) { + if showVersion { + log.Info().Str("version", dspec.Version).Msg("distribution-spec") + } + _ = cmd.Usage() + }, + } + + rootCmd.AddCommand(serveCmd) + rootCmd.AddCommand(gcCmd) + rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "show the version and exit") + + return rootCmd +} diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go new file mode 100644 index 00000000..9951ec3d --- /dev/null +++ b/pkg/cli/root_test.go @@ -0,0 +1,42 @@ +package cli_test + +import ( + "os" + "testing" + + "github.com/anuvu/zot/pkg/cli" + . "github.com/smartystreets/goconvey/convey" +) + +func TestUsage(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + Convey("Test Usage", t, func(c C) { + os.Args = []string{"cli_test", "help"} + err := cli.NewRootCmd().Execute() + So(err, ShouldBeNil) + }) +} + +func TestServe(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + Convey("Test Usage", t, func(c C) { + os.Args = []string{"cli_test", "serve", "-h"} + err := cli.NewRootCmd().Execute() + So(err, ShouldBeNil) + }) +} + +func TestGC(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + Convey("Test Usage", t, func(c C) { + os.Args = []string{"cli_test", "garbage-collect", "-h"} + err := cli.NewRootCmd().Execute() + So(err, ShouldBeNil) + }) +} diff --git a/pkg/storage/BUILD.bazel b/pkg/storage/BUILD.bazel new file mode 100644 index 00000000..fb9a8fe8 --- /dev/null +++ b/pkg/storage/BUILD.bazel @@ -0,0 +1,27 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["storage.go"], + importpath = "github.com/anuvu/zot/pkg/storage", + visibility = ["//visibility:public"], + deps = [ + "//errors:go_default_library", + "@com_github_gofrs_uuid//:go_default_library", + "@com_github_opencontainers_go_digest//:go_default_library", + "@com_github_opencontainers_image_spec//specs-go/v1:go_default_library", + "@com_github_rs_zerolog//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + timeout = "short", + srcs = ["storage_test.go"], + embed = [":go_default_library"], + race = "on", + deps = [ + "@com_github_rs_zerolog//:go_default_library", + "@com_github_smartystreets_goconvey//convey:go_default_library", + ], +) diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go new file mode 100644 index 00000000..12bdfc14 --- /dev/null +++ b/pkg/storage/storage.go @@ -0,0 +1,704 @@ +package storage + +import ( + "encoding/json" + "io" + "io/ioutil" + "os" + "path" + "sync" + + "github.com/anuvu/zot/errors" + guuid "github.com/gofrs/uuid" + godigest "github.com/opencontainers/go-digest" + ispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/rs/zerolog" +) + +const ( + BlobUploadDir = ".uploads" +) + +type BlobUpload struct { + StoreName string + ID string +} + +type ImageStore struct { + rootDir string + lock *sync.Mutex + blobUploads map[string]BlobUpload + log zerolog.Logger +} + +func NewImageStore(rootDir string, log zerolog.Logger) *ImageStore { + is := &ImageStore{rootDir: rootDir, + lock: &sync.Mutex{}, + blobUploads: make(map[string]BlobUpload), + log: log.With().Caller().Logger(), + } + if _, err := os.Stat(rootDir); os.IsNotExist(err) { + _ = os.MkdirAll(rootDir, 0700) + } else if _, err := is.Validate(); err != nil { + panic(err) + } + return is +} + +func (is *ImageStore) Validate() (bool, error) { + dir := is.rootDir + files, err := ioutil.ReadDir(dir) + if err != nil { + is.log.Error().Err(err).Str("dir", dir).Msg("unable to read directory") + return false, errors.ErrRepoNotFound + } + + for _, file := range files { + if !file.IsDir() { + is.log.Error().Err(err).Str("file", file.Name()).Msg("not a directory") + return false, errors.ErrRepoIsNotDir + } + + v, err := is.ValidateRepo(file.Name()) + if !v { + return v, err + } + } + + return true, nil +} + +func (is *ImageStore) InitRepo(name string) error { + repoDir := path.Join(is.rootDir, name) + + if fi, err := os.Stat(repoDir); err == nil && fi.IsDir() { + return nil + } + + // create repo dir + ensureDir(repoDir) + + // create "blobs" subdir + dir := path.Join(repoDir, "blobs") + ensureDir(dir) + + // create BlobUploadDir subdir + dir = path.Join(repoDir, BlobUploadDir) + ensureDir(dir) + + // "oci-layout" file - create if it doesn't exist + ilPath := path.Join(repoDir, ispec.ImageLayoutFile) + if _, err := os.Stat(ilPath); err != nil { + il := ispec.ImageLayout{Version: ispec.ImageLayoutVersion} + buf, err := json.Marshal(il) + if err != nil { + panic(err) + } + if err := ioutil.WriteFile(ilPath, buf, 0644); err != nil { + is.log.Error().Err(err).Str("file", ilPath).Msg("unable to write file") + panic(err) + } + } + + // "index.json" file - create if it doesn't exist + indexPath := path.Join(repoDir, "index.json") + if _, err := os.Stat(indexPath); err != nil { + index := ispec.Index{} + index.SchemaVersion = 2 + buf, err := json.Marshal(index) + if err != nil { + panic(err) + } + if err := ioutil.WriteFile(indexPath, buf, 0644); err != nil { + is.log.Error().Err(err).Str("file", indexPath).Msg("unable to write file") + panic(err) + } + } + + return nil +} + +func (is *ImageStore) ValidateRepo(name string) (bool, error) { + // https://github.com/opencontainers/image-spec/blob/master/image-layout.md#content + // at least, expect exactly 4 entries - ["blobs", "oci-layout", "index.json"] and BlobUploadDir + // in each image store + dir := path.Join(is.rootDir, name) + if !dirExists(dir) { + return false, errors.ErrRepoNotFound + } + + files, err := ioutil.ReadDir(dir) + if err != nil { + is.log.Error().Err(err).Str("dir", dir).Msg("unable to read directory") + return false, errors.ErrRepoNotFound + } + + if len(files) != 4 { + return false, nil + } + + found := map[string]bool{ + "blobs": false, + ispec.ImageLayoutFile: false, + "index.json": false, + BlobUploadDir: false, + } + + for _, file := range files { + if file.Name() == "blobs" && !file.IsDir() { + return false, nil + } + found[file.Name()] = true + } + + for k, v := range found { + if !v && k != BlobUploadDir { + return false, nil + } + } + + buf, err := ioutil.ReadFile(path.Join(dir, ispec.ImageLayoutFile)) + if err != nil { + return false, err + } + + var il ispec.ImageLayout + if err := json.Unmarshal(buf, &il); err != nil { + return false, err + } + + if il.Version != ispec.ImageLayoutVersion { + return false, errors.ErrRepoBadVersion + } + + return true, nil +} + +func (is *ImageStore) GetRepositories() ([]string, error) { + dir := is.rootDir + files, err := ioutil.ReadDir(dir) + if err != nil { + is.log.Error().Err(err).Msg("failure walking storage root-dir") + return nil, err + } + + stores := make([]string, 0) + for _, file := range files { + p := path.Join(dir, file.Name()) + is.log.Debug().Str("dir", p).Str("name", file.Name()).Msg("found image store") + stores = append(stores, file.Name()) + } + return stores, nil +} + +func (is *ImageStore) GetImageTags(repo string) ([]string, error) { + dir := path.Join(is.rootDir, repo) + if !dirExists(dir) { + return nil, errors.ErrRepoNotFound + } + buf, err := ioutil.ReadFile(path.Join(dir, "index.json")) + if err != nil { + is.log.Error().Err(err).Str("dir", dir).Msg("failed to read index.json") + return nil, errors.ErrRepoNotFound + } + + var index ispec.Index + if err := json.Unmarshal(buf, &index); err != nil { + is.log.Error().Err(err).Str("dir", dir).Msg("invalid JSON") + return nil, errors.ErrRepoNotFound + } + + tags := make([]string, 0) + + for _, manifest := range index.Manifests { + v, ok := manifest.Annotations[ispec.AnnotationRefName] + if ok { + tags = append(tags, v) + } + } + + return tags, nil +} + +func (is *ImageStore) GetImageManifest(repo string, reference string) ([]byte, string, string, error) { + dir := path.Join(is.rootDir, repo) + if !dirExists(dir) { + return nil, "", "", errors.ErrRepoNotFound + } + buf, err := ioutil.ReadFile(path.Join(dir, "index.json")) + if err != nil { + is.log.Error().Err(err).Str("dir", dir).Msg("failed to read index.json") + return nil, "", "", err + } + + var index ispec.Index + if err := json.Unmarshal(buf, &index); err != nil { + is.log.Error().Err(err).Str("dir", dir).Msg("invalid JSON") + return nil, "", "", err + } + + found := false + var digest godigest.Digest + mediaType := "" + for _, m := range index.Manifests { + if reference == m.Digest.String() { + digest = m.Digest + mediaType = m.MediaType + found = true + break + } + + v, ok := m.Annotations[ispec.AnnotationRefName] + if ok && v == reference { + digest = m.Digest + mediaType = m.MediaType + found = true + break + } + } + + if !found { + return nil, "", "", errors.ErrManifestNotFound + } + + p := path.Join(dir, "blobs") + p = path.Join(p, digest.Algorithm().String()) + p = path.Join(p, digest.Encoded()) + + buf, err = ioutil.ReadFile(p) + if err != nil { + is.log.Error().Err(err).Str("blob", p).Msg("failed to read manifest") + return nil, "", "", err + } + + var manifest ispec.Manifest + if err := json.Unmarshal(buf, &manifest); err != nil { + is.log.Error().Err(err).Str("dir", dir).Msg("invalid JSON") + return nil, "", "", err + } + + return buf, digest.String(), mediaType, nil +} + +func (is *ImageStore) PutImageManifest(repo string, reference string, + mediaType string, body []byte) (string, error) { + + if err := is.InitRepo(repo); err != nil { + return "", err + } + + if mediaType != ispec.MediaTypeImageManifest { + return "", errors.ErrBadManifest + } + + if len(body) == 0 { + return "", errors.ErrBadManifest + } + + var m ispec.Manifest + if err := json.Unmarshal(body, &m); err != nil { + return "", errors.ErrBadManifest + } + + for _, l := range m.Layers { + digest := l.Digest + blobPath := is.BlobPath(repo, digest) + if _, err := os.Stat(blobPath); err != nil { + return digest.String(), errors.ErrBlobNotFound + } + } + + mDigest := godigest.FromBytes(body) + refIsDigest := false + d, err := godigest.Parse(reference) + if err == nil { + if d.String() != mDigest.String() { + is.log.Error().Str("actual", mDigest.String()).Str("expected", d.String()). + Msg("manifest digest is not valid") + return "", errors.ErrBadManifest + } + refIsDigest = true + } + + dir := path.Join(is.rootDir, repo) + buf, err := ioutil.ReadFile(path.Join(dir, "index.json")) + if err != nil { + is.log.Error().Err(err).Str("dir", dir).Msg("failed to read index.json") + return "", err + } + + var index ispec.Index + if err := json.Unmarshal(buf, &index); err != nil { + is.log.Error().Err(err).Str("dir", dir).Msg("invalid JSON") + return "", errors.ErrRepoBadVersion + } + + updateIndex := true + // create a new descriptor + desc := ispec.Descriptor{MediaType: mediaType, Size: int64(len(body)), Digest: mDigest, + Platform: &ispec.Platform{Architecture: "amd64", OS: "linux"}} + if !refIsDigest { + desc.Annotations = map[string]string{ispec.AnnotationRefName: reference} + } + + for i, m := range index.Manifests { + if reference == m.Digest.String() { + // nothing changed, so don't update + desc = m + updateIndex = false + break + } + + v, ok := m.Annotations[ispec.AnnotationRefName] + if ok && v == reference { + if m.Digest.String() == mDigest.String() { + // nothing changed, so don't update + desc = m + updateIndex = false + break + } + // manifest contents have changed for the same tag + desc = m + desc.Digest = mDigest + index.Manifests = append(index.Manifests[:i], index.Manifests[1+1:]...) + break + } + } + + if !updateIndex { + return desc.Digest.String(), nil + } + + // write manifest to "blobs" + dir = path.Join(is.rootDir, repo) + dir = path.Join(dir, "blobs") + dir = path.Join(dir, mDigest.Algorithm().String()) + _ = os.MkdirAll(dir, 0755) + file := path.Join(dir, mDigest.Encoded()) + if err := ioutil.WriteFile(file, body, 0644); err != nil { + return "", err + } + + // now update "index.json" + index.Manifests = append(index.Manifests, desc) + dir = path.Join(is.rootDir, repo) + file = path.Join(dir, "index.json") + buf, err = json.Marshal(index) + if err != nil { + return "", err + } + if err := ioutil.WriteFile(file, buf, 0644); err != nil { + return "", err + } + + return desc.Digest.String(), nil +} + +func (is *ImageStore) DeleteImageManifest(repo string, reference string) error { + dir := path.Join(is.rootDir, repo) + if !dirExists(dir) { + return errors.ErrRepoNotFound + } + buf, err := ioutil.ReadFile(path.Join(dir, "index.json")) + if err != nil { + is.log.Error().Err(err).Str("dir", dir).Msg("failed to read index.json") + return err + } + + var index ispec.Index + if err := json.Unmarshal(buf, &index); err != nil { + is.log.Error().Err(err).Str("dir", dir).Msg("invalid JSON") + return err + } + + found := false + var digest godigest.Digest + var i int + var m ispec.Descriptor + for i, m = range index.Manifests { + if reference == m.Digest.String() { + digest = m.Digest + found = true + break + } + + v, ok := m.Annotations[ispec.AnnotationRefName] + if ok && v == reference { + digest = m.Digest + found = true + break + } + } + + if !found { + return errors.ErrManifestNotFound + } + + // remove the manifest entry, not preserving order + index.Manifests[i] = index.Manifests[len(index.Manifests)-1] + index.Manifests = index.Manifests[:len(index.Manifests)-1] + + // now update "index.json" + dir = path.Join(is.rootDir, repo) + file := path.Join(dir, "index.json") + buf, err = json.Marshal(index) + if err != nil { + return err + } + if err := ioutil.WriteFile(file, buf, 0644); err != nil { + return err + } + + p := path.Join(dir, "blobs") + p = path.Join(p, digest.Algorithm().String()) + p = path.Join(p, digest.Encoded()) + + _ = os.Remove(p) + + return nil +} + +func (is *ImageStore) BlobUploadPath(repo string, uuid string) string { + dir := path.Join(is.rootDir, repo) + blobUploadPath := path.Join(dir, BlobUploadDir) + blobUploadPath = path.Join(blobUploadPath, uuid) + return blobUploadPath +} + +func (is *ImageStore) NewBlobUpload(repo string) (string, error) { + if err := is.InitRepo(repo); err != nil { + return "", err + } + + uuid, err := guuid.NewV4() + if err != nil { + return "", err + } + + u := uuid.String() + blobUploadPath := is.BlobUploadPath(repo, u) + file, err := os.OpenFile(blobUploadPath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0600) + if err != nil { + return "", errors.ErrRepoNotFound + } + defer file.Close() + + return u, nil +} + +func (is *ImageStore) GetBlobUpload(repo string, uuid string) (int64, error) { + blobUploadPath := is.BlobUploadPath(repo, uuid) + fi, err := os.Stat(blobUploadPath) + if err != nil { + if os.IsNotExist(err) { + return -1, errors.ErrUploadNotFound + } + return -1, err + } + + return fi.Size(), nil +} + +func (is *ImageStore) PutBlobChunk(repo string, uuid string, + from int64, to int64, body io.Reader) (int64, error) { + + if err := is.InitRepo(repo); err != nil { + return -1, err + } + + blobUploadPath := is.BlobUploadPath(repo, uuid) + + fi, err := os.Stat(blobUploadPath) + if err != nil { + return -1, errors.ErrUploadNotFound + } + if from != fi.Size() { + is.log.Error().Int64("expected", from).Int64("actual", fi.Size()). + Msg("invalid range start for blob upload") + return -1, errors.ErrBadUploadRange + } + + file, err := os.OpenFile( + blobUploadPath, + os.O_WRONLY|os.O_CREATE, + 0600, + ) + if err != nil { + is.log.Fatal().Err(err).Msg("failed to open file") + } + defer file.Close() + + if _, err := file.Seek(from, 0); err != nil { + is.log.Fatal().Err(err).Msg("failed to seek file") + } + + n, err := io.Copy(file, body) + return n, err +} + +func (is *ImageStore) BlobUploadInfo(repo string, uuid string) (int64, error) { + blobUploadPath := is.BlobUploadPath(repo, uuid) + fi, err := os.Stat(blobUploadPath) + if err != nil { + is.log.Error().Err(err).Str("blob", blobUploadPath).Msg("failed to stat blob") + return -1, err + } + size := fi.Size() + return size, nil +} + +func (is *ImageStore) FinishBlobUpload(repo string, uuid string, + body io.Reader, digest string) error { + + dstDigest, err := godigest.Parse(digest) + if err != nil { + is.log.Error().Err(err).Str("digest", digest).Msg("failed to parse digest") + return errors.ErrBadBlobDigest + } + + src := is.BlobUploadPath(repo, uuid) + + _, err = os.Stat(src) + if err != nil { + is.log.Error().Err(err).Str("blob", src).Msg("failed to stat blob") + return errors.ErrUploadNotFound + } + + f, err := os.Open(src) + if err != nil { + is.log.Error().Err(err).Str("blob", src).Msg("failed to open blob") + return errors.ErrUploadNotFound + } + srcDigest, err := godigest.FromReader(f) + f.Close() + if err != nil { + is.log.Error().Err(err).Str("blob", src).Msg("failed to open blob") + return errors.ErrBadBlobDigest + } + + if srcDigest != dstDigest { + is.log.Error().Str("srcDigest", srcDigest.String()). + Str("dstDigest", dstDigest.String()).Msg("actual digest not equal to expected digest") + return errors.ErrBadBlobDigest + } + + dir := path.Join(is.rootDir, repo) + dir = path.Join(dir, "blobs") + dir = path.Join(dir, dstDigest.Algorithm().String()) + _ = os.MkdirAll(dir, 0755) + dst := is.BlobPath(repo, dstDigest) + + // move the blob from uploads to final dest + _ = os.Rename(src, dst) + + return err +} + +func (is *ImageStore) DeleteBlobUpload(repo string, uuid string) error { + blobUploadPath := is.BlobUploadPath(repo, uuid) + _ = os.Remove(blobUploadPath) + return nil +} + +func (is *ImageStore) BlobPath(repo string, digest godigest.Digest) string { + dir := path.Join(is.rootDir, repo) + blobPath := path.Join(dir, "blobs") + blobPath = path.Join(blobPath, digest.Algorithm().String()) + blobPath = path.Join(blobPath, digest.Encoded()) + return blobPath +} + +func (is *ImageStore) CheckBlob(repo string, digest string, + mediaType string) (bool, int64, error) { + + d, err := godigest.Parse(digest) + if err != nil { + is.log.Error().Err(err).Str("digest", digest).Msg("failed to parse digest") + return false, -1, errors.ErrBadBlobDigest + } + + blobPath := is.BlobPath(repo, d) + + blobInfo, err := os.Stat(blobPath) + if err != nil { + is.log.Error().Err(err).Str("blob", blobPath).Msg("failed to stat blob") + return false, -1, errors.ErrBlobNotFound + } + + return true, blobInfo.Size(), nil +} + +// FIXME: we should probably parse the manifest and use (digest, mediaType) as a +// blob selector instead of directly downloading the blob +func (is *ImageStore) GetBlob(repo string, digest string, + mediaType string) (io.Reader, int64, error) { + + d, err := godigest.Parse(digest) + if err != nil { + is.log.Error().Err(err).Str("digest", digest).Msg("failed to parse digest") + return nil, -1, errors.ErrBadBlobDigest + } + + blobPath := is.BlobPath(repo, d) + + blobInfo, err := os.Stat(blobPath) + if err != nil { + is.log.Error().Err(err).Str("blob", blobPath).Msg("failed to stat blob") + return nil, -1, errors.ErrBlobNotFound + } + + blobReader, err := os.Open(blobPath) + if err != nil { + is.log.Error().Err(err).Str("blob", blobPath).Msg("failed to open blob") + return nil, -1, err + } + + return blobReader, blobInfo.Size(), nil +} + +func (is *ImageStore) DeleteBlob(repo string, digest string) error { + d, err := godigest.Parse(digest) + if err != nil { + is.log.Error().Err(err).Str("digest", digest).Msg("failed to parse digest") + return errors.ErrBlobNotFound + } + + blobPath := is.BlobPath(repo, d) + + _, err = os.Stat(blobPath) + if err != nil { + is.log.Error().Err(err).Str("blob", blobPath).Msg("failed to stat blob") + return errors.ErrBlobNotFound + } + + _ = os.Remove(blobPath) + + return nil +} + +// garbage collection + +// TODO + +func Scrub(dir string, fix bool) error { + return nil +} + +// utility routines + +func dirExists(d string) bool { + fi, err := os.Stat(d) + if err != nil && os.IsNotExist(err) { + return false + } + if !fi.IsDir() { + return false + } + return true +} + +func ensureDir(dir string) { + if err := os.MkdirAll(dir, 0755); err != nil { + panic(err) + } +} diff --git a/pkg/storage/storage_test.go b/pkg/storage/storage_test.go new file mode 100644 index 00000000..d2840a99 --- /dev/null +++ b/pkg/storage/storage_test.go @@ -0,0 +1,48 @@ +package storage_test + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/anuvu/zot/pkg/storage" + "github.com/rs/zerolog" + . "github.com/smartystreets/goconvey/convey" +) + +func TestRepoLayout(t *testing.T) { + dir, err := ioutil.TempDir("", "oci-repo-test") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + + il := storage.NewImageStore(dir, zerolog.New(os.Stdout)) + + Convey("Repo layout", t, func(c C) { + repoName := "test" + + Convey("Validate repo without initialization", func() { + v, err := il.ValidateRepo(repoName) + So(v, ShouldEqual, false) + So(err, ShouldNotBeNil) + }) + + Convey("Initialize repo", func() { + err := il.InitRepo(repoName) + So(err, ShouldBeNil) + }) + + Convey("Validate repo", func() { + v, err := il.ValidateRepo(repoName) + So(v, ShouldEqual, true) + So(err, ShouldBeNil) + }) + + Convey("Validate all repos", func() { + v, err := il.Validate() + So(v, ShouldEqual, true) + So(err, ShouldBeNil) + }) + }) +} diff --git a/test/data/ca.crt b/test/data/ca.crt new file mode 100644 index 00000000..8544bd74 --- /dev/null +++ b/test/data/ca.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC7jCCAdagAwIBAgIJALuTIoaFxZVtMA0GCSqGSIb3DQEBCwUAMAwxCjAIBgNV +BAMMASowHhcNMTkwNjIwMDIzNzAwWhcNMjkwNjE3MDIzNzAwWjAMMQowCAYDVQQD +DAEqMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx0nMLwovfHblPjVV +0EmdUbgvf4Yz0zhFPQn4g7qsXAYl4RoatUxD0Ow6Ovij6UFTCTi2WDiS+ihnLswp +ZGlHXmdGtmMnltAL7YAADma5cZhvNEdG2mtGkANZ6IiABVPOU7qHUc3IGCBWbpHK +9zywrbv4DN3667C2tFEIt4FNw55uEjpkrF7D7Befc9y4gRPYneGgtWiznQA9vMKi +JvOpxBYbVIujz/BWCzNN/Oavbtd3oJUaObXcr4K/jfaMl/Pc5AVx6OxzlptpleMG +Lg36dza+ChkQ4FsHJw/O1a8Vp3BIbHzXhQev2dKcXGKUElyEqsxEkh72WYjZMmW4 +T2V+CwIDAQABo1MwUTAdBgNVHQ4EFgQUEOS5BfVHrqbQjfUYM8MjPgi+k3MwHwYD +VR0jBBgwFoAUEOS5BfVHrqbQjfUYM8MjPgi+k3MwDwYDVR0TAQH/BAUwAwEB/zAN +BgkqhkiG9w0BAQsFAAOCAQEAPO4r8geI4MufGmaTPE3yRcEfOtZ9d7CTjPYbRyYk +g2p/bO2XVUbpfuwo/n2fctddemkqgW8p0SLS0cdFYHW9TzHYUxhL5BWwVkFTz5O8 ++WrheSkLLR3R4iifNaFL79SEugTH3Alirkz3NjdjPzdql7wHahyxMzPWX+FjYzi1 +eU+dcKIYjWa/Vs2BUwf2jVC1U7Q+SyoTCjCiyAwfqiwBd3qkiZ3ArxoolfidIArF +tA5v6ZHGWP42ZtKxMAz0lfoE3CnjXTVwgtjoIGR0MQ08lPd2PQjtUOMKyYssB2J4 +v3RmDx5ygZQQHJoR+0oMcLuhkJ8g8O0hS3rSlzU6IN6stA== +-----END CERTIFICATE----- diff --git a/test/data/ca.key b/test/data/ca.key new file mode 100644 index 00000000..915ce026 --- /dev/null +++ b/test/data/ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDHScwvCi98duU+ +NVXQSZ1RuC9/hjPTOEU9CfiDuqxcBiXhGhq1TEPQ7Do6+KPpQVMJOLZYOJL6KGcu +zClkaUdeZ0a2YyeW0AvtgAAOZrlxmG80R0baa0aQA1noiIAFU85TuodRzcgYIFZu +kcr3PLCtu/gM3frrsLa0UQi3gU3Dnm4SOmSsXsPsF59z3LiBE9id4aC1aLOdAD28 +wqIm86nEFhtUi6PP8FYLM0385q9u13eglRo5tdyvgr+N9oyX89zkBXHo7HOWm2mV +4wYuDfp3Nr4KGRDgWwcnD87VrxWncEhsfNeFB6/Z0pxcYpQSXISqzESSHvZZiNky +ZbhPZX4LAgMBAAECggEAP+aD2Bl1/HzLKNVFPNI95XQfls5bU8DZQqctzl9O4Pr/ +rlwGcFeR7y2vxjTvqd1OWMicf1E0n43Q+Apyw0WWosiOvfCxQwRWrsK6QePiVnBA +SA0KxQJcz9SjQZJzKkIjCGno9ev72vCThkStRfVp2WtKMCYFTQmOq+bH2r9VRgG3 +IBjsF2Al2YVSew/SgLVkiflsME3EG50QHNHCzBbQf2q0dDDpROVmsph325THdd9A +WJ1BJZD6cxU0WC2Grt0rQP8VrKwRn5nCcR+5buL61hJGPMoMchEUD9qEpaZcSy7J +9sV2WPZPFt2ePsIWIO547O3S/f3kCaNt1jLJ7XY3MQKBgQD8VTcS4mMsIDdV0E1X +DtwD6ZFPn7K6/x5IDKZ6EyuLrL+pcGg2p9v3r+zHSFQkNRZ5KyKfEZ7D8vgFQOA4 +H6MkVjnSvZaIYdbKjeSuBnTAoIeVo5CeTKEUCiS6pifhIh8/HVs7rcW4129P3hCr +mvbBMIZbwXHq10zn8ATwzJUhFwKBgQDKLzvw1pOQqVyF9hRklS7GwEb7qlxHlx6O +3stX7m9yfNnL7qW2CKQTmwxQOatJI/zOgrsXQFTipWZPOcq9eiT8HX6MSiK/0Q0C +HJqjHhEgx2TdtbDBkOfmYhtjUfeynRuQ8+qzkSDHjpLk12SutaqYezCXXbyjVLo0 +7LRAVSDbLQKBgBYK56W5qwomwk63xJnPTX71/2CiRb26HY4TtNNDK3GnJJMLo77q +iPepIZkDA36qOI1bLEoTAviBGBN1aGDeuqSo96ImN6kwStAk9w4QuFA/dbinsjFx +5jxW6oB3lVJAZdRgnyCmfHg6MZobfv9OqTGVKJeJXYczSZ+VQwk6Bej/AoGAKkMT +UXVY5R0xtOLKQngYjfz1GXfz0BcbkRuq/5dcfl7wm7snslQ+D8cSHNbhIem+11/m +Qab112Zha2AWK+MTRgvYPvTkLJpDENTv0fbf960WPW3UI7Hpd3O8a9dfYluKvpLt +1VkZs/zuYZ1Qc2CP502gy5MRckasoZF04BmrQ4UCgYBK+0m7IJDHy8Mjo/9hf/Jy +kcJ21JTvpsl3IqnC5BtpYm/+RRRE4hYczTh/Z0Wlsc2ro2f0U03er72ugjXiJcKl +wD0qQT/HcdgY1Suue//IVLKNX/RaO6R4V//+4E7rGbRznPG2iLau7w/j9eaRX4d8 +YwDdc7C5g8anbO83Ns5xCw== +-----END PRIVATE KEY----- diff --git a/test/data/ca.srl b/test/data/ca.srl new file mode 100644 index 00000000..290a943b --- /dev/null +++ b/test/data/ca.srl @@ -0,0 +1 @@ +93A4FC959A3453F0 diff --git a/test/data/client.crt b/test/data/client.crt new file mode 100644 index 00000000..bac5043c --- /dev/null +++ b/test/data/client.crt @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICqTCCAZECCQCTpPyVmjRT8DANBgkqhkiG9w0BAQsFADAMMQowCAYDVQQDDAEq +MB4XDTE5MDYyMDAyMzcwMFoXDTI5MDYxNzAyMzcwMFowITETMBEGA1UECwwKVGVz +dENsaWVudDEKMAgGA1UEAwwBKjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBALiJ4GscF/7ZNfRgztdoJ8naCwvlZ8Jk2uf3w7saBsuOCDYFop9ZsmNJ6Sac +ds406DmNY/I01JjZYDDE+d4b+a1WF45YXy+O8spQPSlY1sdASCvKU/V/6GPPjt8e +UNsCv37tFawpDJrtoWNMWJETBbdNeSoRWHYAhpda70Jyy5te3S9MJkw/y6IRYGQD +O8AvpeNPBWkqgor98XcXdMW33NGC8rFeYwp4XkixntEhk+7pVDbgcXf4K/awfpsA +OS4eyIssM5Co9rctbmtssYPbbZ31+L67bTGYksrQJaUX0X6qz74xB+0LL4LB2+ww +MohJcF5X5mpPO0JvLfJqsj/hXo8CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEArW0g +m/eWwO4goZIWcVXc7ndGvH0woBUTdUBiZ4zYwnibXkAYrN037osdY5vrLlLHcZSj +qHuHmAnd8N+qcuR+IOQMhPZw6uw/7s+E0N+wro+DnhhzPFfDwFNW7tCKmuuQOlDF +bEcUJQOvPF//XdWVn4QoTbe38gqwqbBKG/I7AYm3qZLOUE8F+WxM9wKXk8dEg/4v +S1sykCtl0g0EobdJcacQpwMrMJYiiahC63CjQAI9oW9CQgQ0ePH7DI6lwCm3ylt1 +ZY5AuKsFnzMea6C/0EDP08EpE2EhuAqk0pmZnuQdS1Q9pJg15NoSVJPM8hgnNzrK ++TrcrDdPcJ6Zeg2EDQ== +-----END CERTIFICATE----- diff --git a/test/data/client.csr b/test/data/client.csr new file mode 100644 index 00000000..d98b6e00 --- /dev/null +++ b/test/data/client.csr @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICZjCCAU4CAQAwITETMBEGA1UECwwKVGVzdENsaWVudDEKMAgGA1UEAwwBKjCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALiJ4GscF/7ZNfRgztdoJ8na +CwvlZ8Jk2uf3w7saBsuOCDYFop9ZsmNJ6Sacds406DmNY/I01JjZYDDE+d4b+a1W +F45YXy+O8spQPSlY1sdASCvKU/V/6GPPjt8eUNsCv37tFawpDJrtoWNMWJETBbdN +eSoRWHYAhpda70Jyy5te3S9MJkw/y6IRYGQDO8AvpeNPBWkqgor98XcXdMW33NGC +8rFeYwp4XkixntEhk+7pVDbgcXf4K/awfpsAOS4eyIssM5Co9rctbmtssYPbbZ31 ++L67bTGYksrQJaUX0X6qz74xB+0LL4LB2+wwMohJcF5X5mpPO0JvLfJqsj/hXo8C +AwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAFm5BhNj51g+BpU8YRKeFVwhb2XBsC +yk5Qp7cV1D60DevFmE3MyzSol6bCSvDbuXRWBI6A6c7ejwlsxMUgScGUinFTMCP0 +IOiVMGp+hz5Y4ZYi77XAvflz8Rj32Tmu6LnKkQ3GmjXmOoMXapPA874PxfxKb9ho +TWaBJ7/6mz4xU/XHZhVn28ijek/wETcACYSsjVK3U52UhSnzjoQMVnkHVgHSIbqE +YpfC1TeUBxerMWVDvZRm6vcp/rRvT06tcyRO5SqGBUOmeXzUBCrn7u9QQayu0yAO +aHSszx9MEp5uW2Pyq4+LAEP5Q4Ke+7BcjWHm9kF48Ilbfy24Q7O6cGqz +-----END CERTIFICATE REQUEST----- diff --git a/test/data/client.key b/test/data/client.key new file mode 100644 index 00000000..4e75dfd1 --- /dev/null +++ b/test/data/client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4ieBrHBf+2TX0 +YM7XaCfJ2gsL5WfCZNrn98O7GgbLjgg2BaKfWbJjSekmnHbONOg5jWPyNNSY2WAw +xPneG/mtVheOWF8vjvLKUD0pWNbHQEgrylP1f+hjz47fHlDbAr9+7RWsKQya7aFj +TFiREwW3TXkqEVh2AIaXWu9CcsubXt0vTCZMP8uiEWBkAzvAL6XjTwVpKoKK/fF3 +F3TFt9zRgvKxXmMKeF5IsZ7RIZPu6VQ24HF3+Cv2sH6bADkuHsiLLDOQqPa3LW5r +bLGD222d9fi+u20xmJLK0CWlF9F+qs++MQftCy+CwdvsMDKISXBeV+ZqTztCby3y +arI/4V6PAgMBAAECggEAetMqD6BvSCyCgNk+Upj8gpkh6RUTbot6OBLsr8eu5iTu +yiYOC0nENdmn2Q8i9DS6rDOzZi5LokBsiYlRVcgA8qHuo8ul7x2R855cVvzOV2gt +oRfVsf0kS+qGCXNAFcVKd8yNND1OKoAnftP9zvF+SHbEQn+xBTlsW6kmvm9xnULw +f3cffwOLZwV5UFymugBEhJt9EiRVjWJJdVt3f29/ljQg4ZJnnCh8UprtKl73Rkya +nVMde6Uq9lD8EyadX6zi3hMSmTO9+qnYIu4rPFdPlE0cVlGRmogMu2FIBVwuZkX3 +NqppTq3uGdagVP6s6NmZjB2m3/rNulK7M5IghDuogQKBgQDqmBlAajATsabOQo71 +Zn7bo5v6a1HHqjXIV2wvYM7Mv88zaQb/QMZWdYgSfcJ1e0Ysu6nu6wGpKYiCVvYd +E8gV/4xrkiB5Gu7owhMGY2XvNOZks9RycNCEyI6NQ/T5fvjnRlGTJCyhLYnH/645 +NUjiAiUHBiljDR0itcxSkWvQcQKBgQDJYIbUMYgQJRcRDUD2eKMczpIw3xXiqK0r +r0NXE+EENDx5RMz+tf+7RtSRe4+QCsXqgRJXXPCmdrJD74MTZ00sycydjIvIM4Vs +0ecAZgB4EwTqq6CrwewMBElqhC8NaiFuamNveQiklsgiUQkWacI2826xrMVltji6 +d7jag8ee/wKBgQDm3/2qCVd7alERmSt8k/yxSFlPoKMBb6AypOcR0aJ0myjeHbUH +LMaFfHIIUMA6QrITgDWDrsEZrIhuTgs1HqzCCZg2nb9bsIgDhkyW8uf0/QjpfpnM +bv6oT4ELwh+sE6v+YJQTzXwmu9xnelgKcUhjNV0fho7grp1H9cc6U2fZ4QKBgC17 +gbhXX5XV6rnNNoj0glK1TUuAd170Hfip4xm9warDaY0yPuKglJvlyYj6UViFNmJa +uJvGwAu471ZsuDwfrsyY34AOCFw1VsNXPUdXwm9cTFX8YZOpfvjP1w0Zwc7T060u +ljrNKWiTLayihNztEhJ7NNsoXIU2fOWQuM2RyfpdAoGAVOKzRPR5B3DNMXXbzT/m +IhmiJ+w+OSgZYL+lejhX4VbV93+LzVsIUez+T/Tqurx9/Pj3SWqJxW6XZFtaL5vZ +pPs2k8yysEv27SSQ6mDnotplyLmFiYJY5VLShzGg5LxzoxzH5y5l8D1c/eS+VF+G +W493RdVuc7hz1lVxuv2fe6k= +-----END PRIVATE KEY----- diff --git a/test/data/gen_certs.sh b/test/data/gen_certs.sh new file mode 100755 index 00000000..c725ed2a --- /dev/null +++ b/test/data/gen_certs.sh @@ -0,0 +1,45 @@ +#!/bin/bash -xe + +openssl req \ + -newkey rsa:2048 \ + -nodes \ + -days 3650 \ + -x509 \ + -keyout ca.key \ + -out ca.crt \ + -subj "/CN=*" + +openssl req \ + -newkey rsa:2048 \ + -nodes \ + -keyout server.key \ + -out server.csr \ + -subj "/OU=TestServer/CN=*" + +openssl x509 \ + -req \ + -days 3650 \ + -sha256 \ + -in server.csr \ + -CA ca.crt \ + -CAkey ca.key \ + -CAcreateserial \ + -out server.crt \ + -extfile <(echo subjectAltName = IP:127.0.0.1) + +openssl req \ + -newkey rsa:2048 \ + -nodes \ + -keyout client.key \ + -out client.csr \ + -subj "/OU=TestClient/CN=*" + +openssl x509 \ + -req \ + -days 3650 \ + -sha256 \ + -in client.csr \ + -CA ca.crt \ + -CAkey ca.key \ + -CAcreateserial \ + -out client.crt diff --git a/test/data/htpasswd b/test/data/htpasswd new file mode 100644 index 00000000..1ae96da4 --- /dev/null +++ b/test/data/htpasswd @@ -0,0 +1 @@ +test:$2y$05$hlbSXDp6hzDLu6VwACS39ORvVRpr3OMR4RlJ31jtlaOEGnPjKZI1m diff --git a/test/data/server.crt b/test/data/server.crt new file mode 100644 index 00000000..6a8be655 --- /dev/null +++ b/test/data/server.crt @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICwzCCAaugAwIBAgIJAJOk/JWaNFPvMA0GCSqGSIb3DQEBCwUAMAwxCjAIBgNV +BAMMASowHhcNMTkwNjIwMDIzNzAwWhcNMjkwNjE3MDIzNzAwWjAhMRMwEQYDVQQL +DApUZXN0U2VydmVyMQowCAYDVQQDDAEqMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAs8ZX1Qp2w2cQLIUIz7LtOitb3E0gv1zuSg8hsG7TNYydQNi06fF2 +VpDEGMFau1ZqwtyP6SsjqGYuT78eIHQKMVXnURviv6vp1/5f07LJNy1eLisF/Ng5 +nkfMR/J4h+yziOeT8CwZfXMLY7u0rti5VqWpV4B8ylGMV79Tz+wXR02xGVQZtcYU +K+WNaf0wWZEOQUeHzNCDc46PDsukBvNDMkeDJUy9MnEzLxx/WVYCt/p9xwan/fj+ +BigSJcG5SzR3MilUEr/pn5PSWgY40Lx8C0W5lnLaO+jaSMSTfhXoCvCLwsgdjA7y +6s9nvApL80+Y8Jt8bhCyu2M1vewrblfacQIDAQABoxMwETAPBgNVHREECDAGhwR/ +AAABMA0GCSqGSIb3DQEBCwUAA4IBAQCIKnzHFciUufTUDIiPYePfmk30XvddOFeT +4WUzNhxPxfv1bWX9iefZTsZAEmSDWeE4qMQuJdvICd426sZT5V/VtWcy/a114mjQ +At97/Y1GMq+XEnS4295S48QiRjahlZd6N+9X70SnHPqo8YX33+j+8aMorvIpDKVk +WBJ0U9prfOhVhm37nHUjemZ/p4oS51XBo79kbXT9tWD63FAAl4SK99/6ZMPXJHoe +OuXZdn1X41983z0cV1Ze9QhSgEZum9lCjeGZt8b6s/EhByG3yDoNpDCHtkmk921w +a/CH4WZvQe3Q+aFp7tk3XrDPfFuxay2IXE6rXSutYMwiQaZEUs2U +-----END CERTIFICATE----- diff --git a/test/data/server.csr b/test/data/server.csr new file mode 100644 index 00000000..c8e94e28 --- /dev/null +++ b/test/data/server.csr @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICZjCCAU4CAQAwITETMBEGA1UECwwKVGVzdFNlcnZlcjEKMAgGA1UEAwwBKjCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALPGV9UKdsNnECyFCM+y7Tor +W9xNIL9c7koPIbBu0zWMnUDYtOnxdlaQxBjBWrtWasLcj+krI6hmLk+/HiB0CjFV +51Eb4r+r6df+X9OyyTctXi4rBfzYOZ5HzEfyeIfss4jnk/AsGX1zC2O7tK7YuVal +qVeAfMpRjFe/U8/sF0dNsRlUGbXGFCvljWn9MFmRDkFHh8zQg3OOjw7LpAbzQzJH +gyVMvTJxMy8cf1lWArf6fccGp/34/gYoEiXBuUs0dzIpVBK/6Z+T0loGONC8fAtF +uZZy2jvo2kjEk34V6Arwi8LIHYwO8urPZ7wKS/NPmPCbfG4QsrtjNb3sK25X2nEC +AwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQCpj3yysx0u7LRQw9EaSZJhZ92vTnqT +KLK1+8GRLLt8obZhq9Iw0s6Q47GRC0dDfu6DwE/sOBPUXXOkdSys+QtqPPHZZPNT +JzezflInuATliHGNbXHiQ9Z9uHsbeiiEi604e85mj+m8rf5LOYYGxhTyNN5AONFZ +6p1R0IMa/9i8PV6G0JgN0Y8JfGYFuJgVM0Le90bSG0q97W+8Rs7DLQqI//2yV20K +PHSRufZoNayh6bVdIimx3ji8/s/VjvI+0hT110RBqUJk8phzZGnKAkiZDMa66weM +y8AzuOsLc7TdtxVBGer+ClTSH/VjyuDIqBqxN2hfeB6yD9qWCu1ysvxy +-----END CERTIFICATE REQUEST----- diff --git a/test/data/server.key b/test/data/server.key new file mode 100644 index 00000000..2feede2f --- /dev/null +++ b/test/data/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCzxlfVCnbDZxAs +hQjPsu06K1vcTSC/XO5KDyGwbtM1jJ1A2LTp8XZWkMQYwVq7VmrC3I/pKyOoZi5P +vx4gdAoxVedRG+K/q+nX/l/Tssk3LV4uKwX82DmeR8xH8niH7LOI55PwLBl9cwtj +u7Su2LlWpalXgHzKUYxXv1PP7BdHTbEZVBm1xhQr5Y1p/TBZkQ5BR4fM0INzjo8O +y6QG80MyR4MlTL0ycTMvHH9ZVgK3+n3HBqf9+P4GKBIlwblLNHcyKVQSv+mfk9Ja +BjjQvHwLRbmWcto76NpIxJN+FegK8IvCyB2MDvLqz2e8CkvzT5jwm3xuELK7YzW9 +7CtuV9pxAgMBAAECggEAVKyTKhDnp1mf0JhIciuAeOl7NuRNDFUlF1TRNVy9tnco +iiaH77h/WH6PHmnT5nDpkCZ60gQzo1mdbopCEl8Vfe9MKHPN9SFv3wA8+mU3SPnh +ZjV1eIYPfXGr0iduhfcDCPSqRXFAAEpzjuIWVFRX12vnuwMVw+VtCNdhDonQ3Q/8 +jpGi1LDjadckmDkf9QbHBiec9Me/oXd18R9npK9yp8zJCvLUhVeWHdFl1YTvK8QE +s9/IffRO/CLofie4VvR4lLT02Hj47jgMfuKyF0Y+qDykT2AxJsBpdIIMy21hLDTp +RoHHbzJlcwL9ITzas/daVWHqFADSvyK7ZfWggxjgAQKBgQDg09Qw3hN98Deo6fsA +rcn1BDflDHLEc0hY/L/NqLb0EnUMYKZSGI9QbyZP3Oh3jG2G/WdOeq9QLpEIrauF +kd5BTDBRgjx0YzwqIu6rv0vwdo5a7+TATETTGH1gZUTmno3yL2b2OdTA33ewyX7o +rwDEYaTg4ACJLwPqT+vwJCaugQKBgQDMs2KjjpXkEZgTz4tbcTQsNL7ulTOcwYR7 +mOsntXTPHSxB9UiTLFvvgo+/okoCUtW1qztDGzdCjilLNc3lcgpHvGS+pX9MtFKo +lsVnw8cUM7kGHEAjoauGCVYmaZNuOCcbhWvaQEPo8424TkC29PCZNHbC6n5gBQMV +ndQfnfoT8QKBgQC54WkGHhWvgfQCy7CilwzqblpoHSqmEUo3iIBr4Jmiob/0Q9Q+ ++99BeSQL03C/pnLHsKrAz94yRM3UhwHQpRFEm2E3gp3I/GK507fQd5Cpdturg7t0 +4ZnljdHa6N9WbLCfE2HlIVstO5URrQYoCshvlOtkoM7QnPZ3uywulzUEAQKBgF4g +vuLm1hYh4QR7E2HhFFSfjIy5HxqeAgWzs652ylfS2l8aI11JsJzaNK+yOMYIwSzg +qEebZDW+mU50V1GCtyd1gf4IrBjhcoEDk5K7e/fWMOaWZwf7d5wS/wJ62ch9Gb6W +A5pAovmjxS9TDH8U8u4AKfxHSAVvSJPQF5LSWgSBAoGBANbFPrVXgcmCxHRAq9U4 +tybOgJuU1MkGHQBW6i3bQZqxBu2A+h7ORBp/mFZzFKUrxaG8YrBqfiQOznQnPLyZ +k0C4sWPSF7CDD9ZjVS86yOYRzBVlCFWSaGttii2rFuuSEdDjPUOoUhO1NcKSevm1 +KqLTO/4DvBVib2nMAPzTt1pZ +-----END PRIVATE KEY----- diff --git a/zot.go b/zot.go new file mode 100644 index 00000000..56e6de1f --- /dev/null +++ b/zot.go @@ -0,0 +1 @@ +package zot