├── .github └── workflows │ ├── e2e.yaml │ ├── oci-test.yaml │ └── test.yml ├── .gitignore ├── Makefile ├── README.md ├── adapters.go ├── bin └── .gitkeep ├── go.mod ├── go.sum ├── handler.go ├── handler_test.go ├── internal ├── errors │ ├── code.go │ ├── errors.go │ └── errors_test.go ├── grammar │ └── regexp.go ├── registry │ ├── registry.go │ └── registry_test.go └── storage │ └── storage.go ├── main.go ├── response.go └── testdata └── .gitkeep /.github/workflows/e2e.yaml: -------------------------------------------------------------------------------- 1 | name: e2e test 2 | on: 3 | push: 4 | branches: 5 | - "master" 6 | pull_request: {} 7 | jobs: 8 | test: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: 14 | - ubuntu-latest 15 | steps: 16 | - name: checkout 17 | uses: actions/checkout@v2 18 | - name: setup go 19 | uses: actions/setup-go@v2 20 | - name: build 21 | run: make build 22 | - name: run local registry 23 | run: ./bin/registry & 24 | - name: add host for test 25 | run: sudo echo "127.0.0.1 codehex-local" | sudo tee -a /etc/hosts 26 | - name: docker pull registry 27 | run: docker pull registry:2 28 | - name: tagging image for test 29 | run: docker tag $(docker images --format '{{.ID}}' --filter=reference='registry') codehex-local:5080/registry:latest 30 | - name: test push 31 | run: docker push codehex-local:5080/registry:latest 32 | - name: test push (cached on the registry) 33 | run: docker push codehex-local:5080/registry:latest 34 | - name: remove local image for testing pull 35 | run: docker rmi codehex-local:5080/registry:latest 36 | - name: test pull 37 | run: docker pull codehex-local:5080/registry:latest 38 | - name: test pull (cached on the local) 39 | run: docker pull codehex-local:5080/registry:latest -------------------------------------------------------------------------------- /.github/workflows/oci-test.yaml: -------------------------------------------------------------------------------- 1 | name: OCI Conformance Tests 2 | on: 3 | push: 4 | branches: 5 | - "master" 6 | pull_request: {} 7 | jobs: 8 | test: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: 14 | - ubuntu-latest 15 | steps: 16 | - name: checkout 17 | uses: actions/checkout@v2 18 | - name: setup go 19 | uses: actions/setup-go@v2 20 | - name: build 21 | run: make build 22 | - name: run local registry 23 | run: ./bin/registry & 24 | - name: add host for test 25 | run: sudo echo "127.0.0.1 codehex-local" | sudo tee -a /etc/hosts 26 | - name: checkout opencontainers/distribution-spec repo 27 | uses: actions/checkout@v2 28 | with: 29 | repository: opencontainers/distribution-spec 30 | path: distribution-spec 31 | ref: db37dc2b2b6c0f8dc1f220e434611c7eaa238ff0 32 | - name: compile test code 33 | run: cd ./distribution-spec/conformance && go test -c 34 | - name: run test 35 | run: ./distribution-spec/conformance/conformance.test 36 | env: 37 | OCI_ROOT_URL: http://codehex-local:5080 38 | OCI_NAMESPACE: myorg/myrepo 39 | OCI_TEST_PULL: 1 40 | OCI_TEST_PUSH: 1 41 | OCI_TEST_CONTENT_DISCOVERY: 1 42 | OCI_TEST_CONTENT_MANAGEMENT: 1 43 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - "master" 6 | pull_request: {} 7 | jobs: 8 | test: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: 14 | - ubuntu-latest 15 | - macOS-latest 16 | - windows-latest 17 | steps: 18 | - name: checkout 19 | uses: actions/checkout@v2 20 | - name: setup go 21 | uses: actions/setup-go@v2 22 | - name: test 23 | run: go test ./... -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | testdata/* 2 | !testdata/.gitkeep 3 | .DS_Store 4 | bin/* 5 | !bin/.gitkeep 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean 2 | clean: 3 | rm -rf testdata/* 4 | build: 5 | go build -o bin/registry -trimpath -ldflags "-w -s" 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Container Registry 2 | 3 | ![test](https://github.com/Code-Hex/container-registry/workflows/test/badge.svg) ![e2e test](https://github.com/Code-Hex/container-registry/workflows/e2e%20test/badge.svg) ![OCI Conformance Tests](https://github.com/Code-Hex/container-registry/workflows/OCI%20Conformance%20Tests/badge.svg) 4 | 5 | The Container Registry is implemented using the file system. 6 | 7 | - ✅ Implemented of the [OCI Distribution Spec](https://github.com/opencontainers/distribution-spec/blob/master/spec.md) 8 | - [x] Pull 9 | - [x] Push 10 | - [x] Content Discovery 11 | - [x] Content Management 12 | - ✅ Supported Docker Client 13 | 14 | ## Why developed? 15 | 16 | I have developed this container registry for learning purposes. And the reason why I implemented this using the file system because I thought it would be easier to understand how images are stored and what kind of files are stored. 17 | 18 | ⚠ BTW, I do not recommend that you run this application in production. 19 | 20 | ## How to try this on your localhost 21 | 22 | Need to fix `/etc/hosts` like below. 23 | 24 | ``` 25 | ... 26 | 27 | # Added by manually 28 | 127.0.0.1 container-registry 29 | # End of section 30 | ``` 31 | 32 | 33 | - Build binary - `make build` 34 | - Run Container Registry - `./bin/registry` 35 | 36 | If you want to clean up in `testdata` directry, let's use `make clean`. 37 | 38 | ### Push 39 | 40 | 1. Pull any images to push - `docker pull registry:2` 41 | 2. Tag to push - `docker tag $(docker images --format '{{.ID}}' --filter=reference='registry') container-registry:5080/registry:latest` 42 | 3. Try push - `docker push container-registry:5080/registry:latest` 43 | 44 | ## Pull 45 | 46 | Try pull if you have completed push steps 47 | 48 | ```sh 49 | $ docker pull container-registry:5080/registry:latest 50 | ``` 51 | 52 | ## debug 53 | 54 | ### docker daemon 55 | 56 | **MacOS** 57 | 58 | ``` 59 | $ tail -f ~/Library/Containers/com.docker.docker/Data/log/vm/dockerd.log 60 | ``` 61 | 62 | ## References 63 | 64 | - [Container image registry - Build Containers the Hard Way (WIP)](https://containers.gitbook.io/build-containers-the-hard-way/#container-image-registry) 65 | - [Open Container Initiative Distribution Specification](https://github.com/opencontainers/distribution-spec/blob/master/spec.md) -------------------------------------------------------------------------------- /adapters.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | // ServerAdapter represents a apply middleware type for http server. 9 | type ServerAdapter func(http.Handler) http.Handler 10 | 11 | // ServerApply applies http server middlewares 12 | func ServerApply(h http.Handler, adapters ...ServerAdapter) http.Handler { 13 | // To process from left to right, iterate from the last one. 14 | for i := len(adapters) - 1; i >= 0; i-- { 15 | h = adapters[i](h) 16 | } 17 | return h 18 | } 19 | 20 | // AccessLogServerAdapter logs access log 21 | func AccessLogServerAdapter() ServerAdapter { 22 | return func(next http.Handler) http.Handler { 23 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 | wrapped := newResponse(w) 25 | next.ServeHTTP(wrapped, r) 26 | log.Println(r.Method, wrapped.statusCode, r.URL.String()) 27 | }) 28 | } 29 | } 30 | 31 | func SetHeaderServerAdapter() ServerAdapter { 32 | return func(next http.Handler) http.Handler { 33 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 | // Important/Required HTTP-Headers 35 | // https://docs.docker.com/registry/deploying/#importantrequired-http-headers 36 | w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0") 37 | w.Header().Set("X-Content-Type-Options", "nosniff") 38 | next.ServeHTTP(w, r) 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /bin/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Hex/container-registry/69a6481d0821e6f294cd3e6abd9f78e386943c40/bin/.gitkeep -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Code-Hex/container-registry 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/Code-Hex/go-router-simple v0.0.1 7 | github.com/google/uuid v1.1.2 8 | github.com/h2non/filetype v1.1.0 9 | github.com/opencontainers/go-digest v1.0.0 10 | github.com/opencontainers/image-spec v1.0.1 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Code-Hex/go-router-simple v0.0.1 h1:/ZZh3dFQZbJW8vI/xKiTWNw05+8B0m+z42EI4fMuQgM= 2 | github.com/Code-Hex/go-router-simple v0.0.1/go.mod h1:nn4Vv52BYvvfId2bgrx8Ke0uq4WEvThdjV9OWuaeLVs= 3 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 4 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 5 | github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= 6 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 7 | github.com/h2non/filetype v1.1.0 h1:Or/gjocJrJRNK/Cri/TDEKFjAR+cfG6eK65NGYB6gBA= 8 | github.com/h2non/filetype v1.1.0/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= 9 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 10 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 11 | github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= 12 | github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= 13 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 14 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 15 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/Code-Hex/container-registry/internal/errors" 8 | ) 9 | 10 | // Response is a wrapper of http.ResponseWriter 11 | type Response struct { 12 | http.ResponseWriter 13 | statusCode int 14 | committed bool 15 | } 16 | 17 | func newResponse(w http.ResponseWriter) *Response { 18 | return &Response{ 19 | ResponseWriter: w, 20 | statusCode: http.StatusOK, 21 | } 22 | } 23 | 24 | var _ http.ResponseWriter = (*Response)(nil) 25 | 26 | // WriteHeader wraps http.ResponseWriter.WriteHeader and 27 | // store status code which is written. 28 | func (r *Response) WriteHeader(statusCode int) { 29 | if r.committed { 30 | return 31 | } 32 | r.committed = true 33 | r.statusCode = statusCode 34 | r.ResponseWriter.WriteHeader(r.statusCode) 35 | } 36 | 37 | // Handler handles http handler and error which is caused in it. 38 | type Handler func(w http.ResponseWriter, r *http.Request) error 39 | 40 | func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 41 | err := h(w, r) 42 | if err == nil { 43 | return 44 | } 45 | log.Println("error:", err) 46 | if err := errors.ServeJSON(w, err); err != nil { 47 | log.Println(err) 48 | w.WriteHeader(http.StatusInternalServerError) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /handler_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/Code-Hex/container-registry/internal/errors" 11 | ) 12 | 13 | func TestResponse_WriteHeader(t *testing.T) { 14 | w := httptest.NewRecorder() 15 | r := newResponse(w) 16 | if r.statusCode != http.StatusOK { 17 | t.Fatalf("want %d, but got %d", http.StatusOK, r.statusCode) 18 | } 19 | r.WriteHeader(http.StatusNotFound) 20 | if r.statusCode != http.StatusNotFound { 21 | t.Fatalf("want %q, but got %q", http.StatusNotFound, r.statusCode) 22 | } 23 | // ignore 24 | r.WriteHeader(http.StatusInternalServerError) 25 | if r.statusCode != http.StatusNotFound { 26 | t.Fatalf("want %q, but got %q", http.StatusNotFound, r.statusCode) 27 | } 28 | } 29 | 30 | func TestHandler_ServeHTTP(t *testing.T) { 31 | tests := []struct { 32 | name string 33 | h Handler 34 | want string 35 | wantStatus int 36 | }{ 37 | { 38 | name: "success", 39 | h: func(w http.ResponseWriter, _ *http.Request) error { 40 | return nil 41 | }, 42 | want: "", 43 | wantStatus: http.StatusOK, 44 | }, 45 | { 46 | name: "failed if std error", 47 | h: func(w http.ResponseWriter, _ *http.Request) error { 48 | return fmt.Errorf("error") 49 | }, 50 | want: `{"code":"UNKNOWN","message":"unknown error"}`, 51 | wantStatus: http.StatusInternalServerError, 52 | }, 53 | { 54 | name: "failed if wrapped error", 55 | h: func(w http.ResponseWriter, _ *http.Request) error { 56 | err := fmt.Errorf("error") 57 | return errors.Wrap(err, 58 | errors.WithStatusCode(http.StatusPreconditionFailed), 59 | ) 60 | }, 61 | want: `{"code":"UNKNOWN","message":"unknown error"}`, 62 | wantStatus: http.StatusPreconditionFailed, 63 | }, 64 | { 65 | name: "failed if blob unknown", 66 | h: func(w http.ResponseWriter, _ *http.Request) error { 67 | err := fmt.Errorf("error") 68 | return errors.Wrap(err, 69 | errors.WithCodeBlobUnknown(), 70 | ) 71 | }, 72 | want: `{"code":"BLOB_UNKNOWN","message":"blob unknown to registry"}`, 73 | wantStatus: http.StatusNotFound, 74 | }, 75 | } 76 | for _, tt := range tests { 77 | t.Run(tt.name, func(t *testing.T) { 78 | w := httptest.NewRecorder() 79 | tt.h.ServeHTTP(w, nil) 80 | gotBody := strings.TrimSpace(w.Body.String()) 81 | if gotBody != tt.want { 82 | t.Fatalf("want body %q, but got %q", tt.want, w.Body.String()) 83 | } 84 | if w.Code != tt.wantStatus { 85 | t.Fatalf("want statuscode %q, but got %q", tt.wantStatus, w.Code) 86 | } 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /internal/errors/code.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import "net/http" 4 | 5 | // WithCodeUnknown is a generic error that can be used as a last 6 | // resort if there is no situation-specific error message that can be used 7 | func WithCodeUnknown() WrapOption { 8 | return func(e *Error) { 9 | e.Code = "UNKNOWN" 10 | e.Message = "unknown error" 11 | e.StatusCode = http.StatusInternalServerError 12 | } 13 | } 14 | 15 | // WithCodeUnsupported is returned when an operation is not supported. 16 | func WithCodeUnsupported() WrapOption { 17 | return func(e *Error) { 18 | e.Code = "UNSUPPORTED" 19 | e.Message = "The operation is unsupported." 20 | e.StatusCode = http.StatusMethodNotAllowed 21 | } 22 | } 23 | 24 | // ----- Error Code spec 25 | // 26 | // see: https://github.com/opencontainers/distribution-spec/blob/master/spec.md#error-codes 27 | // ----- 28 | 29 | // WithCodeDigestInvalid is returned when uploading a blob if the 30 | // provided digest does not match the blob contents. 31 | func WithCodeDigestInvalid() WrapOption { 32 | return func(e *Error) { 33 | e.Code = "DIGEST_INVALID" 34 | e.Message = "provided digest did not match uploaded content" 35 | e.StatusCode = http.StatusBadRequest 36 | } 37 | } 38 | 39 | // WithCodeSizeInvalid is returned when uploading a blob if the provided 40 | func WithCodeSizeInvalid() WrapOption { 41 | return func(e *Error) { 42 | e.Code = "SIZE_INVALID" 43 | e.Message = "provided length did not match content length" 44 | e.StatusCode = http.StatusBadRequest 45 | } 46 | } 47 | 48 | // WithCodeNameInvalid is returned when the name in the manifest does not 49 | // match the provided name. 50 | func WithCodeNameInvalid() WrapOption { 51 | return func(e *Error) { 52 | e.Code = "NAME_INVALID" 53 | e.Message = "invalid repository name" 54 | e.StatusCode = http.StatusBadRequest 55 | } 56 | } 57 | 58 | // WithCodeTagInvalid is returned when the tag in the manifest does not 59 | // match the provided tag. 60 | func WithCodeTagInvalid() WrapOption { 61 | return func(e *Error) { 62 | e.Code = "TAG_INVALID" 63 | e.Message = "manifest tag did not match URI" 64 | e.StatusCode = http.StatusBadRequest 65 | } 66 | } 67 | 68 | // WithCodeNameUnknown when the repository name is not known. 69 | func WithCodeNameUnknown() WrapOption { 70 | return func(e *Error) { 71 | e.Code = "NAME_UNKNOWN" 72 | e.Message = "repository name not known to registry" 73 | e.StatusCode = http.StatusNotFound 74 | } 75 | } 76 | 77 | // WithCodeManifestUnknown returned when image manifest is unknown. 78 | func WithCodeManifestUnknown() WrapOption { 79 | return func(e *Error) { 80 | e.Code = "MANIFEST_UNKNOWN" 81 | e.Message = "manifest unknown" 82 | e.StatusCode = http.StatusNotFound 83 | } 84 | } 85 | 86 | // WithCodeManifestInvalid returned when an image manifest is invalid, 87 | // typically during a PUT operation. This error encompasses all errors 88 | // encountered during manifest validation that aren't signature errors. 89 | func WithCodeManifestInvalid() WrapOption { 90 | return func(e *Error) { 91 | e.Code = "MANIFEST_INVALID" 92 | e.Message = "manifest invalid" 93 | e.StatusCode = http.StatusBadRequest 94 | } 95 | } 96 | 97 | // WithCodeManifestUnverified is returned when the manifest fails 98 | // signature verification. 99 | func WithCodeManifestUnverified() WrapOption { 100 | return func(e *Error) { 101 | e.Code = "MANIFEST_UNVERIFIED" 102 | e.Message = "manifest failed signature verification" 103 | e.StatusCode = http.StatusBadRequest 104 | } 105 | } 106 | 107 | // WithCodeManifestBlobUnknown is returned when a manifest blob is 108 | // unknown to the registry. 109 | func WithCodeManifestBlobUnknown() WrapOption { 110 | return func(e *Error) { 111 | e.Code = "MANIFEST_BLOB_UNKNOWN" 112 | e.Message = "blob unknown to registry" 113 | e.StatusCode = http.StatusBadRequest 114 | } 115 | } 116 | 117 | // WithCodeBlobUnknown is returned when a blob is unknown to the 118 | // registry. This can happen when the manifest references a nonexistent 119 | // layer or the result is not found by a blob fetch. 120 | func WithCodeBlobUnknown() WrapOption { 121 | return func(e *Error) { 122 | e.Code = "BLOB_UNKNOWN" 123 | e.Message = "blob unknown to registry" 124 | e.StatusCode = http.StatusNotFound 125 | } 126 | } 127 | 128 | // WithCodeBlobUploadUnknown is returned when an upload is unknown. 129 | func WithCodeBlobUploadUnknown() WrapOption { 130 | return func(e *Error) { 131 | e.Code = "BLOB_UPLOAD_UNKNOWN" 132 | e.Message = "blob upload unknown to registry" 133 | e.StatusCode = http.StatusRequestedRangeNotSatisfiable 134 | } 135 | } 136 | 137 | // WithCodeBlobUploadInvalid is returned when an upload is invalid. 138 | func WithCodeBlobUploadInvalid() WrapOption { 139 | return func(e *Error) { 140 | e.Code = "BLOB_UPLOAD_INVALID" 141 | e.Message = "blob upload invalid" 142 | e.StatusCode = http.StatusNotFound 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /internal/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | // Error for using wrapped error. 10 | type Error struct { 11 | Err error `json:"-"` 12 | StatusCode int `json:"-"` 13 | 14 | // https://github.com/opencontainers/distribution-spec/blob/master/spec.md#error-codes 15 | Code string `json:"code"` 16 | Message string `json:"message"` 17 | Detail interface{} `json:"detail,omitempty"` 18 | } 19 | 20 | // WrapOption represents option for Wrap function. 21 | type WrapOption func(e *Error) 22 | 23 | // WithStatusCode wraps error with http status code. 24 | func WithStatusCode(sc int) WrapOption { 25 | return func(e *Error) { 26 | e.StatusCode = sc 27 | } 28 | } 29 | 30 | // WithDetail wraps error with detail. 31 | func WithDetail(detail interface{}) WrapOption { 32 | return func(e *Error) { 33 | e.Detail = detail 34 | } 35 | } 36 | 37 | // Wrap wraps error which is also sets other fields. 38 | func Wrap(err error, opts ...WrapOption) *Error { 39 | wrapped := &Error{Err: err} 40 | WithCodeUnknown()(wrapped) // initialize 41 | for _, wo := range opts { 42 | wo(wrapped) 43 | } 44 | return wrapped 45 | } 46 | 47 | func (e *Error) Error() string { 48 | if e.Err == nil { 49 | return "" 50 | } 51 | if e.Detail == nil { 52 | return e.Err.Error() 53 | } 54 | // best effort 55 | v, _ := json.Marshal(e.Detail) 56 | if len(v) != 0 { 57 | return fmt.Sprintf("err: %q, detail: %v", e.Err, string(v)) 58 | } 59 | return e.Err.Error() 60 | } 61 | 62 | func (e *Error) Unwrap() error { 63 | if e.Err == nil { 64 | return nil 65 | } 66 | return e.Err 67 | } 68 | 69 | // ServeJSON attempts to serve the errcode in a JSON envelope. It marshals err 70 | // and sets the content-type header to 'application/json'. It will handle 71 | // Error and some errors which is converted to Error, and if necessary will create an envelope. 72 | func ServeJSON(w http.ResponseWriter, err error) error { 73 | if err == nil { 74 | return nil 75 | } 76 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 77 | 78 | e := func(err error) *Error { 79 | switch e := err.(type) { 80 | case *Error: 81 | return e 82 | } 83 | return Wrap(err) 84 | }(err) 85 | 86 | w.WriteHeader(e.StatusCode) 87 | 88 | return json.NewEncoder(w).Encode(e) 89 | } 90 | -------------------------------------------------------------------------------- /internal/errors/errors_test.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestServeJSON(t *testing.T) { 12 | err := fmt.Errorf("error") 13 | tests := []struct { 14 | name string 15 | err error 16 | want string 17 | wantStatus int 18 | }{ 19 | { 20 | name: "success", 21 | err: nil, 22 | want: "", 23 | wantStatus: http.StatusOK, 24 | }, 25 | { 26 | name: "failed if std error", 27 | err: err, 28 | want: `{"code":"UNKNOWN","message":"unknown error"}`, 29 | wantStatus: http.StatusInternalServerError, 30 | }, 31 | { 32 | name: "failed if wrapped error", 33 | err: Wrap(err, 34 | WithStatusCode(http.StatusPreconditionFailed), 35 | ), 36 | want: `{"code":"UNKNOWN","message":"unknown error"}`, 37 | wantStatus: http.StatusPreconditionFailed, 38 | }, 39 | { 40 | name: "failed if blob unknown", 41 | err: Wrap(err, 42 | WithCodeBlobUnknown(), 43 | ), 44 | want: `{"code":"BLOB_UNKNOWN","message":"blob unknown to registry"}`, 45 | wantStatus: http.StatusNotFound, 46 | }, 47 | } 48 | for _, tt := range tests { 49 | t.Run(tt.name, func(t *testing.T) { 50 | w := httptest.NewRecorder() 51 | ServeJSON(w, tt.err) 52 | gotBody := strings.TrimSpace(w.Body.String()) 53 | if gotBody != tt.want { 54 | t.Fatalf("want body %q, but got %q", tt.want, w.Body.String()) 55 | } 56 | if w.Code != tt.wantStatus { 57 | t.Fatalf("want statuscode %q, but got %q", tt.wantStatus, w.Code) 58 | } 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /internal/grammar/regexp.go: -------------------------------------------------------------------------------- 1 | package grammar 2 | 3 | // Grammar: https://github.com/docker/distribution/blob/2800ab02245e2eafc10e338939511dd1aeb5e135/reference/reference.go#L4-L24 4 | const ( 5 | Reference = Name + (`(?::` + Tag + `)?`) + (`(?:@` + Digest + `)?`) 6 | 7 | Name = (`(?:` + Domain + `\/)?`) + PathComponent + (`(?:\/` + PathComponent + `)*`) 8 | Domain = DomainComponent + (`(?:\.` + DomainComponent + `)*`) + ("(?::" + PortNumber + ")?") 9 | DomainComponent = `(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])` 10 | PortNumber = `[0-9]+` 11 | Tag = `[\w][\w.-]{0,127}` 12 | PathComponent = AlphaNumeric + (`(?:` + Separator + AlphaNumeric + `)*`) 13 | AlphaNumeric = `[a-z0-9]+` 14 | Separator = `[_.]|__|[-]*` 15 | 16 | Digest = DigestAlgorithm + ":" + DigestHex 17 | DigestAlgorithm = DigestAlgorithmComponent + (`(?:` + DigestAlgorithmSeparator + DigestAlgorithmComponent + `)*`) 18 | DigestAlgorithmSeparator = `[+.-_]` 19 | DigestAlgorithmComponent = `[A-Za-z][A-Za-z0-9]*` 20 | DigestHex = `[0-9a-fA-F]{32,}` 21 | ) 22 | -------------------------------------------------------------------------------- /internal/registry/registry.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/h2non/filetype" 12 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 13 | ) 14 | 15 | // Manifest represents manifest schema v2. 16 | // https://docs.docker.com/registry/spec/manifest-v2-2/ 17 | type Manifest struct { 18 | SchemaVersion int `json:"schemaVersion"` 19 | MediaType string `json:"mediaType"` 20 | Config ocispec.Descriptor `json:"config"` 21 | Layers []ocispec.Descriptor `json:"layers"` 22 | } 23 | 24 | // BasePath represents base path for this application. 25 | var BasePath = "testdata" 26 | 27 | // PathJoinWithBase joins any number of path elements with base path into a single path, 28 | // separating them with an OS specific Separator. 29 | func PathJoinWithBase(name string, p ...string) string { 30 | return filepath.Join( 31 | append( 32 | []string{ 33 | BasePath, 34 | name, 35 | }, 36 | p..., 37 | )..., 38 | ) 39 | } 40 | 41 | // CreateLayer creates layer a file which will be json or gz extension on specified path. 42 | func CreateLayer(r io.Reader, path string) (int64, error) { 43 | // see filetype.MatchReader 44 | buffer := make([]byte, 8192) 45 | n, err := r.Read(buffer) 46 | if err != nil && err != io.EOF { 47 | return 0, err 48 | } 49 | 50 | filePath := filepath.Join(path, "layer"+detectExt(buffer)) 51 | f, err := os.Create(filePath) 52 | if err != nil { 53 | return 0, err 54 | } 55 | defer f.Close() 56 | return io.Copy(f, io.MultiReader(bytes.NewReader(buffer[:n]), r)) 57 | } 58 | 59 | func detectExt(buf []byte) string { 60 | if filetype.IsArchive(buf) { 61 | return ".tar.gz" 62 | } 63 | return ".json" 64 | } 65 | 66 | // PickupFileinfo picks up one file in the specified directory. 67 | // This function is expected to use if there's only one file in the directory. 68 | func PickupFileinfo(dir string) (os.FileInfo, error) { 69 | fis, err := ioutil.ReadDir(dir) 70 | if err != nil { 71 | return nil, err 72 | } 73 | if len(fis) == 0 { 74 | return nil, fmt.Errorf("there is no file in %q directory", dir) 75 | } 76 | return fis[0], nil 77 | } 78 | 79 | // PredictDockerContentType predicts content type by filename. 80 | func PredictDockerContentType(filename string) string { 81 | ext := filepath.Ext(filename) 82 | if ext == ".json" { 83 | return "application/vnd.docker.distribution.manifest.v2+json" 84 | } 85 | return "application/vnd.docker.image.rootfs.diff.tar.gzip" 86 | } 87 | -------------------------------------------------------------------------------- /internal/registry/registry_test.go: -------------------------------------------------------------------------------- 1 | package registry_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/Code-Hex/container-registry/internal/registry" 12 | ) 13 | 14 | func TestMain(m *testing.M) { 15 | registry.BasePath = "testdata" 16 | os.Exit(m.Run()) 17 | } 18 | 19 | func TestPathJoinWithBase(t *testing.T) { 20 | type args struct { 21 | name string 22 | p []string 23 | } 24 | tests := []struct { 25 | name string 26 | args args 27 | basePath string 28 | want string 29 | }{ 30 | { 31 | name: "simple", 32 | args: args{ 33 | name: "library/hello-world", 34 | p: []string{ 35 | "digest", 36 | "layer.tar.gz", 37 | }, 38 | }, 39 | basePath: "base", 40 | want: filepath.Join("base", "library/hello-world", "digest", "layer.tar.gz"), 41 | }, 42 | } 43 | for _, tt := range tests { 44 | t.Run(tt.name, func(t *testing.T) { 45 | registry.BasePath = tt.basePath 46 | if got := registry.PathJoinWithBase(tt.args.name, tt.args.p...); got != tt.want { 47 | t.Errorf("PathJoinWithBase() = %v, want %v", got, tt.want) 48 | } 49 | }) 50 | } 51 | } 52 | 53 | func TestCreateLayer(t *testing.T) { 54 | type args struct { 55 | r io.Reader 56 | path string 57 | } 58 | tests := []struct { 59 | name string 60 | args args 61 | want int64 62 | }{ 63 | { 64 | name: "tar.gz", 65 | args: args{ 66 | // gz magic number 67 | // https://github.com/h2non/filetype/blob/29039c24a9fbddaf40b7ae847d38f7ceafb94dd0/matchers/archive.go#L96-L99 68 | r: bytes.NewReader([]byte{0x1f, 0x8b, 0x8}), 69 | }, 70 | want: 3, 71 | }, 72 | { 73 | name: "json", 74 | args: args{ 75 | r: bytes.NewReader([]byte{'{', '}'}), 76 | }, 77 | want: 2, 78 | }, 79 | } 80 | for _, tt := range tests { 81 | t.Run(tt.name, func(t *testing.T) { 82 | dir, err := ioutil.TempDir("", "") 83 | if err != nil { 84 | t.Fatalf("TempDir: %v", err) 85 | } 86 | got, err := registry.CreateLayer(tt.args.r, dir) 87 | if err != nil { 88 | t.Fatalf("CreateLayer() error = %v", err) 89 | } 90 | if got != tt.want { 91 | t.Errorf("CreateLayer() int64 got = %v, want %v", got, tt.want) 92 | } 93 | }) 94 | } 95 | } 96 | 97 | func TestPickupFileinfo(t *testing.T) { 98 | tests := []struct { 99 | name string 100 | wantErr bool 101 | }{ 102 | { 103 | name: "valid", 104 | wantErr: false, 105 | }, 106 | { 107 | name: "invalid", 108 | wantErr: true, 109 | }, 110 | } 111 | for _, tt := range tests { 112 | t.Run(tt.name, func(t *testing.T) { 113 | dir, err := ioutil.TempDir("", "") 114 | if err != nil { 115 | t.Fatalf("TempDir: %v", err) 116 | } 117 | 118 | var want string 119 | if !tt.wantErr { 120 | f, err := ioutil.TempFile(dir, "") 121 | if err != nil { 122 | t.Fatalf("TempFile: %v", err) 123 | } 124 | f.Close() 125 | want = filepath.Base(f.Name()) 126 | } 127 | 128 | got, err := registry.PickupFileinfo(dir) 129 | if tt.wantErr { 130 | if err == nil { 131 | t.Fatal("expected error") 132 | } 133 | return 134 | } 135 | if err != nil { 136 | t.Fatalf("PickupFileinfo() error = %v", err) 137 | } 138 | if want != got.Name() { 139 | t.Fatalf("name want: %v, but got %v", want, got.Name()) 140 | } 141 | }) 142 | } 143 | } 144 | 145 | func TestPredictContentType(t *testing.T) { 146 | tests := []struct { 147 | name string 148 | filename string 149 | want string 150 | }{ 151 | { 152 | name: "want json", 153 | filename: "file.json", 154 | want: "application/vnd.docker.distribution.manifest.v2+json", 155 | }, 156 | { 157 | name: "want gzip", 158 | filename: "file.tar.gz", 159 | want: "application/vnd.docker.image.rootfs.diff.tar.gzip", 160 | }, 161 | } 162 | for _, tt := range tests { 163 | t.Run(tt.name, func(t *testing.T) { 164 | if got := registry.PredictDockerContentType(tt.filename); got != tt.want { 165 | t.Errorf("PredictContentType() = %v, want %v", got, tt.want) 166 | } 167 | }) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "os" 12 | "path/filepath" 13 | 14 | "github.com/Code-Hex/container-registry/internal/errors" 15 | "github.com/Code-Hex/container-registry/internal/registry" 16 | "github.com/google/uuid" 17 | "github.com/opencontainers/go-digest" 18 | ) 19 | 20 | // Repository represents the storage behavior. 21 | type Repository interface { 22 | // Push 23 | IssueSession() string 24 | PutBlobByReference(ref string, imgName string, body io.Reader) (int64, error) 25 | EnsurePutBlobBySession(sessionID string, imgName string, digest string) error 26 | CheckBlobByReference(imgName string, ref string) (os.FileInfo, error) 27 | CreateManifest(body io.Reader, name string, tag string) (*registry.Manifest, string, error) 28 | 29 | // Pull 30 | FindBlobByImage(name, digest string) (*os.File, error) 31 | FindManifestByImage(name, ref string) (*registry.Manifest, error) 32 | 33 | // Delete 34 | DeleteManifestByImage(name, tag string) error 35 | DeleteBlobByImage(name, digest string) error 36 | } 37 | 38 | const baseTagDir = "tags" 39 | 40 | var _ Repository = (*Local)(nil) 41 | 42 | // Local implemented Repository using local storage. 43 | type Local struct{} 44 | 45 | // IssueSession issues session ID. 46 | func (l *Local) IssueSession() string { 47 | return uuid.New().String() 48 | } 49 | 50 | // PutBlobByReference tries to put uploaded file on the reference directory. 51 | // 52 | // first, this method creates directory like "testdata//" 53 | // then, put the layer file onto it. 54 | func (l *Local) PutBlobByReference(ref string, imgName string, body io.Reader) (int64, error) { 55 | path := registry.PathJoinWithBase(imgName, ref) 56 | os.MkdirAll(path, 0700) 57 | return registry.CreateLayer(body, path) 58 | } 59 | 60 | // EnsurePutBlobBySession ensures the temporary path created by PutBlobBySession. 61 | // 62 | // this method moves from the temporary directory to "testdata//" directory 63 | func (l *Local) EnsurePutBlobBySession(sessionID string, imgName string, digest string) error { 64 | newDir := registry.PathJoinWithBase(imgName, digest) 65 | os.MkdirAll(newDir, 0700) 66 | 67 | oldDir := registry.PathJoinWithBase(imgName, sessionID) 68 | fi, err := registry.PickupFileinfo(oldDir) 69 | if err != nil { 70 | return err 71 | } 72 | filename := fi.Name() 73 | oldpath := filepath.Join(oldDir, filename) 74 | newpath := filepath.Join(newDir, filename) 75 | if err := os.Rename(oldpath, newpath); err != nil { 76 | return err 77 | } 78 | os.Remove(oldDir) 79 | return nil 80 | } 81 | 82 | // CheckBlobByReference checks for the existence of a blob with a ref. 83 | func (l *Local) CheckBlobByReference(imgName string, ref string) (os.FileInfo, error) { 84 | dir := registry.PathJoinWithBase(imgName, ref) 85 | if _, err := os.Stat(dir); os.IsNotExist(err) { 86 | return nil, errors.Wrap(err, 87 | errors.WithStatusCode(http.StatusNotFound), 88 | ) 89 | } 90 | return registry.PickupFileinfo(dir) 91 | } 92 | 93 | // CreateManifest creates manifest json file by name and tag. 94 | // 95 | // this method creates to "//manifest.json" 96 | func (l *Local) CreateManifest(body io.Reader, name string, tag string) (*registry.Manifest, string, error) { 97 | hash := sha256.New() 98 | reader := io.TeeReader(body, hash) 99 | var m registry.Manifest 100 | if err := json.NewDecoder(reader).Decode(&m); err != nil { 101 | return nil, "", errors.Wrap(err, 102 | errors.WithCodeManifestInvalid(), 103 | ) 104 | } 105 | sha256sum := fmt.Sprintf("sha256:%x", hash.Sum(nil)) 106 | 107 | // create directory 108 | path := registry.PathJoinWithBase(name, baseTagDir) 109 | os.MkdirAll(path, 0700) 110 | 111 | // create tag file 112 | tagPath := filepath.Join(path, tag) 113 | tagFile, err := os.Create(tagPath) 114 | if err != nil { 115 | return nil, "", errors.Wrap(err, 116 | errors.WithCodeTagInvalid(), 117 | ) 118 | } 119 | tagFile.Write([]byte(sha256sum)) 120 | tagFile.Close() 121 | 122 | manifestPath := registry.PathJoinWithBase(name, sha256sum) 123 | os.MkdirAll(manifestPath, 0700) 124 | 125 | // create manifest file onto it 126 | manifestPath = filepath.Join(manifestPath, "manifest.json") 127 | manifestF, err := os.Create(manifestPath) 128 | if err != nil { 129 | return nil, "", errors.Wrap(err, 130 | errors.WithCodeTagInvalid(), 131 | ) 132 | } 133 | defer manifestF.Close() 134 | if err := json.NewEncoder(manifestF).Encode(&m); err != nil { 135 | return nil, "", err 136 | } 137 | return &m, sha256sum, nil 138 | } 139 | 140 | // FindBlobByImage finds blob by docker image name and that's digest. 141 | // 142 | // digest format is like :. see grammar.Digest 143 | func (l *Local) FindBlobByImage(name, digest string) (*os.File, error) { 144 | dir := registry.PathJoinWithBase(name, digest) 145 | if _, err := os.Stat(dir); os.IsNotExist(err) { 146 | return nil, errors.Wrap(err, 147 | errors.WithCodeBlobUnknown(), 148 | ) 149 | } 150 | fi, err := registry.PickupFileinfo(dir) 151 | if err != nil { 152 | return nil, err 153 | } 154 | path := filepath.Join(dir, fi.Name()) 155 | return os.Open(path) 156 | } 157 | 158 | // FindManifestByImage finds manifest json file by image name and that's tag. 159 | func (l *Local) FindManifestByImage(name, ref string) (*registry.Manifest, error) { 160 | tagFilePath := registry.PathJoinWithBase(name, baseTagDir, ref) 161 | if _, err := os.Stat(tagFilePath); err == nil { 162 | digest, err := ioutil.ReadFile(tagFilePath) 163 | if err != nil { 164 | return nil, errors.Wrap(err) 165 | } 166 | ref = string(digest) 167 | } 168 | 169 | manifest := registry.PathJoinWithBase(name, ref, "manifest.json") 170 | if _, err := os.Stat(manifest); os.IsNotExist(err) { 171 | return nil, errors.Wrap(err, 172 | errors.WithCodeManifestUnknown(), 173 | ) 174 | } 175 | f, err := os.Open(manifest) 176 | if err != nil { 177 | return nil, err 178 | } 179 | defer f.Close() 180 | var m registry.Manifest 181 | if err := json.NewDecoder(f).Decode(&m); err != nil { 182 | return nil, err 183 | } 184 | return &m, nil 185 | } 186 | 187 | // DeleteManifestByImage deletes manifest json file by image name and that's tag. 188 | func (l *Local) DeleteManifestByImage(name, ref string) (err error) { 189 | if _, err := digest.Parse(ref); err != nil { 190 | // remove tag too 191 | tag := registry.PathJoinWithBase(name, baseTagDir, ref) 192 | dgst, err := ioutil.ReadFile(tag) 193 | if err != nil { 194 | log.Println("-----------", err, tag) 195 | return errors.Wrap(err) 196 | } 197 | os.Remove(tag) 198 | ref = string(dgst) 199 | } 200 | 201 | manifestDir := registry.PathJoinWithBase(name, ref) 202 | manifest := filepath.Join(manifestDir, "manifest.json") 203 | if _, err := os.Stat(manifest); os.IsNotExist(err) { 204 | return errors.Wrap(err, 205 | errors.WithStatusCode(http.StatusAccepted), 206 | ) 207 | } 208 | return os.RemoveAll(manifestDir) 209 | } 210 | 211 | // DeleteBlobByImage deletes blob by docker image name and that's digest. 212 | // 213 | // digest format is like :. see grammar.Digest 214 | func (l *Local) DeleteBlobByImage(name, digest string) error { 215 | dir := registry.PathJoinWithBase(name, digest) 216 | if _, err := os.Stat(dir); os.IsNotExist(err) { 217 | return errors.Wrap(err, 218 | errors.WithCodeBlobUnknown(), 219 | ) 220 | } 221 | return os.RemoveAll(dir) 222 | } 223 | 224 | // ListTags lists tags by image name. 225 | func (l *Local) ListTags(name string) ([]string, error) { 226 | path := registry.PathJoinWithBase(name, baseTagDir) 227 | fis, err := ioutil.ReadDir(path) 228 | if err != nil { 229 | if os.IsNotExist(err) { 230 | return nil, errors.Wrap(err, 231 | errors.WithStatusCode(http.StatusNotFound), 232 | ) 233 | } 234 | return nil, err 235 | } 236 | tags := make([]string, len(fis)) 237 | for i, tag := range fis { 238 | tags[i] = tag.Name() 239 | } 240 | return tags, nil 241 | } 242 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | e "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net" 11 | "net/http" 12 | "os" 13 | "os/signal" 14 | "strconv" 15 | "strings" 16 | "syscall" 17 | 18 | "github.com/Code-Hex/container-registry/internal/errors" 19 | "github.com/Code-Hex/container-registry/internal/grammar" 20 | "github.com/Code-Hex/container-registry/internal/registry" 21 | "github.com/Code-Hex/container-registry/internal/storage" 22 | "github.com/Code-Hex/go-router-simple" 23 | digest "github.com/opencontainers/go-digest" 24 | ) 25 | 26 | const ( 27 | // GET represents GET method 28 | GET = http.MethodGet 29 | // POST represents POST method 30 | POST = http.MethodPost 31 | // PATCH represents PATCH method 32 | PATCH = http.MethodPatch 33 | // PUT represents PUT method 34 | PUT = http.MethodPut 35 | // DELETE represents DELETE method 36 | DELETE = http.MethodDelete 37 | ) 38 | 39 | const hostname = "localhost:5080" 40 | 41 | var unsupportedHandler = Handler(func(w http.ResponseWriter, r *http.Request) error { 42 | err := fmt.Errorf("unsupported") 43 | return errors.Wrap(err, errors.WithCodeUnsupported()) 44 | }) 45 | 46 | // spec 47 | // https://github.com/opencontainers/distribution-spec/blob/master/spec.md 48 | func main() { 49 | rs := router.New() 50 | 51 | // https://github.com/opencontainers/distribution-spec/blob/master/spec.md#endpoints 52 | rs.GET("/v2/", DeterminingSupport()) 53 | 54 | // /v2/:name/blobs/:digest 55 | rs.GET( 56 | fmt.Sprintf( 57 | `/v2/{name:%s}/blobs/{digest:%s}`, 58 | grammar.Name, grammar.Digest, 59 | ), 60 | PullingBlobs(), 61 | ) 62 | 63 | // /v2/:name/manifests/:reference 64 | rs.GET( 65 | fmt.Sprintf( 66 | `/v2/{name:%s}/manifests/{reference:%s}`, 67 | grammar.Name, grammar.Reference, 68 | ), 69 | PullingManifests(), 70 | ) 71 | 72 | // /?digest= 73 | rs.POST( 74 | fmt.Sprintf( 75 | `/v2/{name:%s}/blobs/uploads/`, 76 | grammar.Name, 77 | ), 78 | PushBlobPost(), 79 | ) 80 | 81 | rs.PATCH( 82 | fmt.Sprintf( 83 | `/v2/{name:%s}/blobs/uploads/{reference:%s}`, 84 | grammar.Name, grammar.Reference, 85 | ), 86 | PushBlobPatch(), 87 | ) 88 | 89 | // /?digest= 90 | rs.PUT( 91 | fmt.Sprintf( 92 | `/v2/{name:%s}/blobs/uploads/{reference:%s}`, 93 | grammar.Name, grammar.Reference, 94 | ), 95 | PushBlobPut(), 96 | ) 97 | 98 | rs.HEAD( 99 | fmt.Sprintf( 100 | `/v2/{name:%s}/blobs/{digest:%s}`, 101 | grammar.Name, grammar.Digest, 102 | ), 103 | PushBlobHead(), 104 | ) 105 | 106 | // Group -- /v2//manifests/ 107 | rs.PUT( 108 | fmt.Sprintf( 109 | `/v2/{name:%s}/manifests/{tag:%s}`, 110 | grammar.Name, grammar.Tag, 111 | ), 112 | PushManifestPut(), 113 | ) 114 | rs.PUT( 115 | fmt.Sprintf( 116 | `/v2/{name:%s}/manifests/{digest:%s}`, 117 | grammar.Name, grammar.Digest, 118 | ), 119 | unsupportedHandler, 120 | ) 121 | // Group End 122 | 123 | // /?n=&last= 124 | rs.GET( 125 | fmt.Sprintf( 126 | "/v2/{name:%s}/tags/list", 127 | grammar.Name, 128 | ), 129 | ListTags(), 130 | ) 131 | 132 | rs.DELETE( 133 | fmt.Sprintf( 134 | `/v2/{name:%s}/manifests/{reference:%s}`, 135 | grammar.Name, grammar.Reference, 136 | ), 137 | DeleteManifest(), 138 | ) 139 | 140 | rs.DELETE( 141 | fmt.Sprintf( 142 | "/v2/{name:%s}/blobs/{digest:%s}", 143 | grammar.Name, grammar.Digest, 144 | ), 145 | DeleteBlob(), 146 | ) 147 | 148 | srv := &http.Server{ 149 | Handler: ServerApply(rs, AccessLogServerAdapter(), SetHeaderServerAdapter()), 150 | } 151 | errCh := make(chan struct{}) 152 | go func() { 153 | addr := hostname 154 | log.Printf("running %q", addr) 155 | ln, err := net.Listen("tcp", addr) 156 | if err != nil { 157 | log.Printf("error: %v", err) 158 | close(errCh) 159 | return 160 | } 161 | if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { 162 | log.Fatal(err) 163 | } 164 | }() 165 | 166 | sig := make(chan os.Signal, 1) 167 | signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) 168 | 169 | select { 170 | case <-sig: 171 | case <-errCh: 172 | } 173 | 174 | if err := srv.Shutdown(context.Background()); err != nil { 175 | log.Printf("shutdown error: %v\n", err) 176 | } 177 | } 178 | 179 | // DeterminingSupport to check whether or not the registry implements this specification. 180 | // If the response is 200 OK, then the registry implements this specification. 181 | // This endpoint MAY be used for authentication/authorization purposes, but this is out of the purview of this specification. 182 | func DeterminingSupport() http.Handler { 183 | return Handler(func(w http.ResponseWriter, r *http.Request) error { 184 | w.Header().Set("Content-Type", "application/json") 185 | w.Write([]byte{'{', '}'}) 186 | return nil 187 | }) 188 | } 189 | 190 | // PullingBlobs to pull a blob. 191 | // 192 | // To pull a blob, perform a GET request to a url in the following form: /v2//blobs/ 193 | // is the namespace of the repository, and is the blob's digest. 194 | func PullingBlobs() http.Handler { 195 | s := new(storage.Local) 196 | return Handler(func(w http.ResponseWriter, r *http.Request) error { 197 | ctx := r.Context() 198 | dq := router.ParamFromContext(ctx, "digest") 199 | dgst, err := digest.Parse(dq) 200 | if err != nil { 201 | return errors.Wrap(err, 202 | errors.WithCodeDigestInvalid(), 203 | ) 204 | } 205 | name := router.ParamFromContext(ctx, "name") 206 | f, err := s.FindBlobByImage(name, dgst.String()) 207 | if err != nil { 208 | return err 209 | } 210 | defer f.Close() 211 | w.Header().Set("Content-Type", registry.PredictDockerContentType(f.Name())) 212 | _, err = io.Copy(w, f) 213 | return err 214 | }) 215 | } 216 | 217 | // PullingManifests to pull a manifest. 218 | // 219 | // To pull a manifest, perform a GET request to a url in the following form: /v2//manifests/ 220 | // refers to the namespace of the repository. is a tag name. 221 | func PullingManifests() http.Handler { 222 | s := new(storage.Local) 223 | return Handler(func(w http.ResponseWriter, r *http.Request) error { 224 | ctx := r.Context() 225 | name := router.ParamFromContext(ctx, "name") 226 | ref := router.ParamFromContext(ctx, "reference") 227 | m, err := s.FindManifestByImage(name, ref) 228 | if err != nil { 229 | return err 230 | } 231 | w.Header().Set("Content-Type", registry.PredictDockerContentType("manifest.json")) 232 | return json.NewEncoder(w).Encode(m) 233 | }) 234 | } 235 | 236 | // PushBlobPost a handler to push a blob. this handler issues session ID to push image. 237 | // 238 | // To push a blob monolithically by using a single POST request, perform a POST request to a URL in the following form: /v2//blobs/uploads 239 | // refers to the namespace of the repository. 240 | func PushBlobPost() http.Handler { 241 | s := new(storage.Local) 242 | return Handler(func(w http.ResponseWriter, r *http.Request) error { 243 | name := router.ParamFromContext(r.Context(), "name") 244 | if r.Header.Get("Content-Type") != "application/octet-stream" { 245 | sessionID := s.IssueSession() 246 | location := "/v2/" + name + "/blobs/uploads/" + sessionID 247 | w.Header().Set("Location", location) 248 | w.WriteHeader(http.StatusAccepted) 249 | return nil 250 | } 251 | 252 | // For Pushing a blob monolithically: // only POST 253 | // https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pushing-a-blob-monolithically 254 | dgst, err := digest.Parse(r.URL.Query().Get("digest")) 255 | if err != nil { 256 | return errors.Wrap(err, 257 | errors.WithCodeDigestInvalid(), 258 | ) 259 | } 260 | d := dgst.String() 261 | 262 | if _, err := s.PutBlobByReference(d, name, r.Body); err != nil { 263 | return err 264 | } 265 | pullableLoc := "/v2/" + name + "/blobs/" + d 266 | w.Header().Set("Location", pullableLoc) 267 | w.WriteHeader(http.StatusCreated) 268 | return nil 269 | }) 270 | } 271 | 272 | // PushBlobPatch a handler to push a blob. this handler accepts image body and put to storage by session ID. 273 | // 274 | // Pushing a blob in chunks: POST (Obtain a session ID) -> PATCH (Upload the chunks) -> PUT (Close the session) 275 | // perform a PATCH request to a URL in the following form: /v2//blobs/uploads/ 276 | // refers to the namespace of the repository, will be session ID. 277 | func PushBlobPatch() http.Handler { 278 | s := new(storage.Local) 279 | return Handler(func(w http.ResponseWriter, r *http.Request) error { 280 | ctx := r.Context() 281 | name := router.ParamFromContext(ctx, "name") 282 | sessionID := router.ParamFromContext(ctx, "reference") 283 | contentRange := r.Header.Get("Content-Range") 284 | contentLength := r.Header.Get("Content-Length") 285 | 286 | // If does not specify content-range or content-length, accepts request 287 | // as full upload of the file. 288 | if contentRange == "" || contentLength == "" { 289 | size, err := s.PutBlobByReference(sessionID, name, r.Body) 290 | if err != nil { 291 | return err 292 | } 293 | location := "/v2/" + name + "/blobs/uploads/" + sessionID 294 | w.Header().Set("Location", location) 295 | w.Header().Set("Docker-Upload-UUID", sessionID) 296 | w.Header().Set("Range", fmt.Sprintf("0-%d", size)) 297 | w.WriteHeader(http.StatusAccepted) 298 | return nil 299 | } 300 | 301 | // From here accepted Content-Range request. 302 | bodyLen, err := strconv.ParseInt(contentLength, 10, 64) 303 | if err != nil { 304 | return errors.Wrap(err, 305 | errors.WithCodeBlobUnknown(), 306 | errors.WithStatusCode(http.StatusBadRequest), 307 | ) 308 | } 309 | 310 | // We have to care for "bytes " prefix on Content-Range by rfc7233. 311 | // But distribution-spec/conformance test did not use this prefix. 312 | // see: https://github.com/opencontainers/distribution-spec/pull/203 313 | idx := strings.Index(contentRange, "bytes ") 314 | if idx != -1 { 315 | contentRange = contentRange[idx+1:] 316 | } 317 | 318 | var start, end int 319 | _, err = fmt.Sscanf(contentRange, "%d-%d", &start, &end) 320 | if err != nil { 321 | return errors.Wrap(err, 322 | errors.WithCodeBlobUploadUnknown(), 323 | ) 324 | } 325 | var fsize int64 326 | info, err := s.CheckBlobByReference(name, sessionID) 327 | if err == nil { 328 | fsize = info.Size() 329 | } 330 | if !os.IsNotExist(e.Unwrap(err)) { 331 | return err 332 | } 333 | // Example of range request: 334 | // Content-Range: bytes 21010-47021/47022 335 | // Content-Length: 26012 336 | if int64(start) != fsize || int64(end-start+1) != bodyLen { 337 | return errors.Wrap(err, 338 | errors.WithCodeBlobUploadUnknown(), 339 | ) 340 | } 341 | if start == 0 { 342 | size, err := s.PutBlobByReference(sessionID, name, r.Body) 343 | if err != nil { 344 | return err 345 | } 346 | w.Header().Set("Accept-Ranges", "bytes") 347 | w.Header().Set("Range", fmt.Sprintf("0-%d", size)) 348 | } else { 349 | path := registry.PathJoinWithBase(name, sessionID) 350 | f, err := os.Open(path) 351 | if err != nil { 352 | return err 353 | } 354 | defer f.Close() 355 | size, err := io.Copy(f, r.Body) 356 | if err != nil { 357 | return err 358 | } 359 | w.Header().Set("Accept-Ranges", "bytes") 360 | w.Header().Set("Range", fmt.Sprintf("%d-%d", start, int64(start)+size)) 361 | } 362 | 363 | location := "/v2/" + name + "/blobs/uploads/" + sessionID 364 | w.Header().Set("Location", location) 365 | w.Header().Set("Docker-Upload-UUID", sessionID) 366 | w.WriteHeader(http.StatusAccepted) 367 | return nil 368 | }) 369 | } 370 | 371 | // PushBlobPut a handler to push a blob. this handler moves image to ensured storage 372 | // from has been put to storage by session ID before. 373 | // 374 | // perform a PUT request to a URL in the following form: /v2//blobs/uploads/?digest= 375 | // refers to the namespace of the repository, will be session ID. is digest. 376 | func PushBlobPut() http.Handler { 377 | s := new(storage.Local) 378 | return Handler(func(w http.ResponseWriter, r *http.Request) error { 379 | dgst, err := digest.Parse(r.URL.Query().Get("digest")) 380 | if err != nil { 381 | return errors.Wrap(err, 382 | errors.WithCodeDigestInvalid(), 383 | ) 384 | } 385 | ctx := r.Context() 386 | name := router.ParamFromContext(ctx, "name") 387 | sessionID := router.ParamFromContext(ctx, "reference") 388 | 389 | // For Pushing a blob monolithically: // POST -> PUT 390 | // https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pushing-a-blob-monolithically 391 | contentType := r.Header.Get("Content-Type") 392 | if contentType == "application/octet-stream" { 393 | _, err := s.PutBlobByReference(dgst.String(), name, r.Body) 394 | if err != nil { 395 | return err 396 | } 397 | pullableLoc := "/v2/" + name + "/blobs/" + dgst.String() 398 | w.Header().Set("Location", pullableLoc) 399 | w.WriteHeader(http.StatusCreated) 400 | return nil 401 | } 402 | 403 | // Pushing a blob in chunks 404 | // POST -> PATCH -> PUT 405 | if err := s.EnsurePutBlobBySession(sessionID, name, dgst.String()); err != nil { 406 | return err 407 | } 408 | w.WriteHeader(http.StatusCreated) 409 | return nil 410 | }) 411 | } 412 | 413 | // PushBlobHead a handler to push a blob. this handler checks image is pushed completely. 414 | // 415 | // perform a HEAD request to a URL in the following form: /v2//blobs/ 416 | // refers to the namespace of the repository, is digest. 417 | func PushBlobHead() http.Handler { 418 | s := new(storage.Local) 419 | return Handler(func(w http.ResponseWriter, r *http.Request) error { 420 | ctx := r.Context() 421 | dq := router.ParamFromContext(ctx, "digest") 422 | dgst, err := digest.Parse(dq) 423 | if err != nil { 424 | return errors.Wrap(err, 425 | errors.WithCodeDigestInvalid(), 426 | ) 427 | } 428 | name := router.ParamFromContext(ctx, "name") 429 | fi, err := s.CheckBlobByReference(name, dgst.String()) 430 | if err != nil { 431 | return err 432 | } 433 | w.Header().Set("Content-Type", registry.PredictDockerContentType(fi.Name())) 434 | w.Header().Set("Docker-Content-Digest", dgst.String()) 435 | w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10)) 436 | w.WriteHeader(http.StatusAccepted) 437 | return nil 438 | }) 439 | } 440 | 441 | // PushManifestPut a handler to push a manifest json file. 442 | // 443 | // perform a PUT request to a URL in the following form: /v2//manifests/ 444 | // refers to the namespace of the repository. is a tag name. 445 | func PushManifestPut() http.Handler { 446 | s := new(storage.Local) 447 | return Handler(func(w http.ResponseWriter, r *http.Request) error { 448 | ctx := r.Context() 449 | name := router.ParamFromContext(ctx, "name") 450 | tag := router.ParamFromContext(ctx, "tag") 451 | _, sha256sum, err := s.CreateManifest(r.Body, name, tag) 452 | if err != nil { 453 | return err 454 | } 455 | pullableLoc := "/v2/" + name + "/manifests/" + tag 456 | w.Header().Set("Docker-Content-Digest", sha256sum) 457 | w.Header().Set("Location", pullableLoc) 458 | w.WriteHeader(http.StatusCreated) 459 | return nil 460 | }) 461 | } 462 | 463 | // DeleteManifest a handler to delete a manifest json. 464 | // 465 | // perform a DELETE request to a URL in the following form: /v2//manifests/ 466 | // refers to the namespace of the repository. is the name of the tag to be deleted. 467 | func DeleteManifest() http.Handler { 468 | s := new(storage.Local) 469 | return Handler(func(w http.ResponseWriter, r *http.Request) error { 470 | ctx := r.Context() 471 | name := router.ParamFromContext(ctx, "name") 472 | tag := router.ParamFromContext(ctx, "reference") 473 | if err := s.DeleteManifestByImage(name, tag); err != nil { 474 | return err 475 | } 476 | w.WriteHeader(http.StatusAccepted) 477 | return nil 478 | }) 479 | } 480 | 481 | // DeleteBlob a handler to delete a blob. 482 | // 483 | // perform a DELETE request to a URL in the following form: /v2//blobs/ 484 | // refers to the namespace of the repository, is digest. 485 | func DeleteBlob() http.Handler { 486 | s := new(storage.Local) 487 | return Handler(func(w http.ResponseWriter, r *http.Request) error { 488 | ctx := r.Context() 489 | name := router.ParamFromContext(ctx, "name") 490 | digest := router.ParamFromContext(ctx, "digest") 491 | if err := s.DeleteBlobByImage(name, digest); err != nil { 492 | return err 493 | } 494 | w.WriteHeader(http.StatusAccepted) 495 | return nil 496 | }) 497 | } 498 | 499 | // ListTags a handler to list tags. 500 | // 501 | // perform a GET request to a path in the following format: /v2//tags/list 502 | // is the namespace of the repository. 503 | func ListTags() http.Handler { 504 | type Tags struct { 505 | Name string `json:"name"` 506 | Tags []string `json:"tags"` 507 | } 508 | s := new(storage.Local) 509 | return Handler(func(w http.ResponseWriter, r *http.Request) error { 510 | ctx := r.Context() 511 | name := router.ParamFromContext(ctx, "name") 512 | q := r.URL.Query() 513 | nq, lastq := q.Get("n"), q.Get("last") 514 | tags, err := s.ListTags(name) 515 | if err != nil { 516 | return err 517 | } 518 | 519 | n := len(tags) 520 | if nq != "" { 521 | n, err = strconv.Atoi(nq) 522 | if err != nil { 523 | return err 524 | } 525 | } 526 | retTags := make([]string, 0, n) 527 | for i, v := range tags { 528 | if i == n || v == lastq { 529 | break 530 | } 531 | retTags = append(retTags, v) 532 | } 533 | resp := &Tags{ 534 | Name: name, 535 | Tags: retTags, 536 | } 537 | return json.NewEncoder(w).Encode(resp) 538 | }) 539 | } 540 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | // ErrorResponse : 9 | // error codes: https://github.com/opencontainers/distribution-spec/blob/master/spec.md#error-codes 10 | type ErrorResponse struct { 11 | Errors []Error `json:"errors"` 12 | } 13 | 14 | // Error : 15 | type Error struct { 16 | Code string `json:"code"` 17 | Message string `json:"message"` 18 | Detail string `json:"detail"` 19 | } 20 | 21 | func writeErrorResponse(w http.ResponseWriter, code int, er *ErrorResponse) error { 22 | w.Header().Set("Content-Type", "application/json") 23 | w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0") 24 | w.WriteHeader(http.StatusNotFound) 25 | return json.NewEncoder(w).Encode(er) 26 | } 27 | -------------------------------------------------------------------------------- /testdata/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Hex/container-registry/69a6481d0821e6f294cd3e6abd9f78e386943c40/testdata/.gitkeep --------------------------------------------------------------------------------