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