diff --git a/ociclient/authbas.go b/ociclient/authbas.go new file mode 100644 index 0000000..1b555eb --- /dev/null +++ b/ociclient/authbas.go @@ -0,0 +1,20 @@ +package repocli + +import ( + "encoding/base64" +) + +type Authenticator interface { + MakeHeader(user, pass string) (key, value string, err error) +} + +type BasicAuthenticator struct{} + +func NewBasicAuthenticator() *BasicAuthenticator { + return &BasicAuthenticator{} +} + +func (auth *BasicAuthenticator) MakeHeader(user, pass string) (string, string, error) { + pair := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass)) + return "Authorization", "Basic " + pair, nil +} diff --git a/ociclient/blobexist.go b/ociclient/blobexist.go new file mode 100644 index 0000000..3f4ab09 --- /dev/null +++ b/ociclient/blobexist.go @@ -0,0 +1,50 @@ +package repocli + +import ( + "context" + "fmt" + "net/http" + "strconv" +) + +func (cli *Client) BlobExists(ctx context.Context, rawrepo string, digest string) (bool, int64, error) { + var err error + var exist bool + var size int64 + + ref, err := NewRepository(rawrepo) + if err != nil { + return exist, size, err + } + uri := ref.Blob(digest) + + fmt.Println(uri) + req, err := http.NewRequestWithContext(ctx, http.MethodHead, uri, nil) + if err != nil { + return exist, size, err + } + req.Header.Set("User-Agent", cli.userAgent) + req.Header.Set("Accept", "*/*") + + resp, err := cli.httpClient.Do(req) + if err != nil { + return exist, size, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return exist, size, err + } + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("Unxected response code %s", resp.Status) + return exist, size, err + } + contentLength := resp.Header.Get("Content-Length") + size, err = strconv.ParseInt(contentLength, 10, 64) + if err != nil { + return exist, size, err + } + + exist = true + return exist, size, err +} diff --git a/ociclient/client.go b/ociclient/client.go new file mode 100644 index 0000000..0983196 --- /dev/null +++ b/ociclient/client.go @@ -0,0 +1,136 @@ +package repocli + +import ( + "crypto/tls" + "encoding/base64" + "net/http" +) + +type Client struct { + httpClient *http.Client + userAgent string +} + +func NewClient() *Client { + defaultTripper := NewDefaultTransport() + httpClient := &http.Client{ + Transport: defaultTripper, + } + return &Client{ + httpClient: httpClient, + userAgent: "ociClient/1.0", + } +} + +func NewClientWithTransport(transport http.RoundTripper, mwFunc ...MiddlewareFunc) *Client { + if transport == nil { + transport = NewDefaultTransport() + } + httpClient := &http.Client{ + Transport: transport, + } + return &Client{ + httpClient: httpClient, + userAgent: "ociClient/1.0", + } +} + +func (cli *Client) SetTransport(transport http.RoundTripper) { + cli.httpClient.Transport = transport +} + +type MiddlewareFunc func(next http.RoundTripper) http.RoundTripper + +func (cli *Client) UseMiddleware(mwFunc MiddlewareFunc) { + cli.httpClient.Transport = mwFunc(cli.httpClient.Transport) +} + +// ExampleMiddleware +func NewExampleMiddleware() MiddlewareFunc { + return func(next http.RoundTripper) http.RoundTripper { + return newExampleTransport(next) + } +} + +type ExampleTransport struct { + next http.RoundTripper +} + +func newExampleTransport(next http.RoundTripper) *ExampleTransport { + return &ExampleTransport{ + next: next, + } +} + +func (tran ExampleTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return tran.next.RoundTrip(req) +} + +// BasicAuthMiddleware +func NewBasicAuthMiddleware(user, pass string) MiddlewareFunc { + return func(next http.RoundTripper) http.RoundTripper { + return newBasicAuthMW(next, user, pass) + } +} + +type BasicAuthMW struct { + user, pass string + next http.RoundTripper +} + +func newBasicAuthMW(next http.RoundTripper, user, pass string) *BasicAuthMW { + return &BasicAuthMW{ + user: user, + pass: pass, + next: next, + } +} + +func (tran BasicAuthMW) RoundTrip(req *http.Request) (*http.Response, error) { + pair := base64.StdEncoding.EncodeToString([]byte(tran.user + ":" + tran.pass)) + req.Header.Set("Authorization", "Basic "+pair) + return tran.next.RoundTrip(req) +} + +// BearerAuthMiddleware +func NewBearerAuthMiddleware(token string) MiddlewareFunc { + return func(next http.RoundTripper) http.RoundTripper { + return newBearerAuthMW(next, token) + } +} + +type BearerAuthMW struct { + token string + next http.RoundTripper +} + +func newBearerAuthMW(next http.RoundTripper, token string) *BearerAuthMW { + return &BearerAuthMW{ + token: token, + next: next, + } +} + +func (tran BearerAuthMW) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", "Bearer "+tran.token) + return tran.next.RoundTrip(req) +} + +// DefaultTransport +type DefaultTransport struct { + transport http.RoundTripper +} + +func NewDefaultTransport() *DefaultTransport { + return &DefaultTransport{ + transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } +} + +func (wrap *DefaultTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return wrap.transport.RoundTrip(req) +} diff --git a/ociclient/client_test.go b/ociclient/client_test.go new file mode 100644 index 0000000..efcb220 --- /dev/null +++ b/ociclient/client_test.go @@ -0,0 +1,159 @@ +package repocli + +import ( + "github.com/stretchr/testify/require" + + "bytes" + "context" + "encoding/json" + "fmt" + "math/rand" + "testing" + "time" +) + +func xxxTestClientGetManifest(t *testing.T) { + rawrepo := "mirror.gcr.io/alpine" + tags := []string{ + "3.20.0", + "sha256:29e5ba63e79337818e6c63cfcc68e2ab4e9ca483853b2de303bfbfba9372426c", + } + for _, tag := range tags { + cli := NewClient() + ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) + exist, mime, man, err := cli.GetManifest(ctx, rawrepo, tag) + require.NoError(t, err) + require.True(t, exist) + + fmt.Printf("Type: %s\n", mime) + buffer := bytes.NewBuffer(nil) + err = json.Indent(buffer, man, " ", " ") + require.NoError(t, err) + //fmt.Printf("%s\n", buffer.String()) + } +} + +func xxxTestClientManifestExists(t *testing.T) { + rawrepo := "mirror.gcr.io/alpine" + tags := []string{ + "3.20.0", + "sha256:29e5ba63e79337818e6c63cfcc68e2ab4e9ca483853b2de303bfbfba9372426c", + } + for _, tag := range tags { + + cli := NewClient() + ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) + exist, mime, size, csum, err := cli.ManifestExists(ctx, rawrepo, tag) + require.NoError(t, err) + require.True(t, exist) + + fmt.Printf("MIME: %s\n", mime) + fmt.Printf("Size: %d\n", size) + fmt.Printf("Sum: %s\n", csum) + fmt.Printf("Typ: %d\n", DigestType(csum)) + } +} + +func xxxTestClientBlobExists(t *testing.T) { + rawrepos := []string{ + "mirror.gcr.io/alpine", + } + for _, rawrepo := range rawrepos { + cli := NewClient() + ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) + digest := "sha256:3b8747b05489980f63da1d2b8e5a444c55777f69540394397b0bc1c76c3e41f2" + exist, size, err := cli.BlobExists(ctx, rawrepo, digest) + require.NoError(t, err) + require.True(t, exist) + + fmt.Printf("Size: %d\n", size) + } +} + +func xxxTestClientGetBlob(t *testing.T) { + rawrepos := []string{ + "mirror.gcr.io/alpine", + } + for _, rawrepo := range rawrepos { + cli := NewClient() + ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) + buffer := bytes.NewBuffer(nil) + digest := "sha256:3b8747b05489980f63da1d2b8e5a444c55777f69540394397b0bc1c76c3e41f2" + exist, err := cli.GetBlob(ctx, rawrepo, buffer, digest) + require.NoError(t, err) + require.True(t, exist) + fmt.Printf("Size: %d\n", len(buffer.Bytes())) + } +} + +func xxxxTestClientGetUpload(t *testing.T) { + rawrepos := []string{ + "mstore:mstore@localhost:1025/alpine:3.20.0", + } + cli := NewClient() + cli.UseMiddleware(NewBasicAuthMiddleware("mstore", "mstore")) + + for _, rawrepo := range rawrepos { + var err error + var loc string + { + ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) + loc, err = cli.GetUpload(ctx, rawrepo) + require.NoError(t, err) + fmt.Printf("Upload Location: %s\n", loc) + } + { + srcsize := 1024 + 145 + srcdata := make([]byte, srcsize) + _, err = rand.Read(srcdata) + require.NoError(t, err) + + src := bytes.NewReader(srcdata) + //digest := SHA256Digest(srcdata) + + ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) + bloc, err := cli.PatchUpload(ctx, rawrepo, src, loc, int64(len(srcdata))) + require.NoError(t, err) + fmt.Printf("Path Location: %s\n", bloc) + } + { + srcsize := 1024 + 145 + srcdata := make([]byte, srcsize) + _, err = rand.Read(srcdata) + require.NoError(t, err) + + src := bytes.NewReader(srcdata) + digest := SHA256Digest(srcdata) + + ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) + bloc, err := cli.PutUpload(ctx, rawrepo, src, loc, digest, int64(len(srcdata))) + require.NoError(t, err) + fmt.Printf("Put blob Location: %s\n", bloc) + } + + } +} + +func xxxxTestClientGetToken(t *testing.T) { + var token string + var err error + { + cli := NewClient() + cli.UseMiddleware(NewBasicAuthMiddleware("onborodin", "2Albert334")) + ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) + token, err = cli.GetToken(ctx, "https://auth.docker.io/token") + require.NoError(t, err) + + } + fmt.Printf("Token: %s\n", token) + { + rawrepo := "docker.io/onborodin/toolbox:0.18" + cli := NewClient() + cli.UseMiddleware(NewBearerAuthMiddleware(token)) + ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) + loc, err := cli.GetUpload(ctx, rawrepo) + require.NoError(t, err) + fmt.Printf("Upload Location: %s\n", loc) + } + +} diff --git a/ociclient/copyctx.go b/ociclient/copyctx.go new file mode 100644 index 0000000..b9a8acf --- /dev/null +++ b/ociclient/copyctx.go @@ -0,0 +1,39 @@ +package repocli + +import ( + "context" + "errors" + "io" +) + +func Copy(ctx context.Context, writer io.Writer, reader io.Reader) (int64, error) { + var err error + var size int64 + var halt bool + buffer := make([]byte, 1024*4) + for { + select { + case <-ctx.Done(): + err = errors.New("Break copy by context") + break + default: + } + rsize, err := reader.Read(buffer) + if err == io.EOF { + err = nil + halt = true + } + if err != nil { + return size, err + } + wsize, err := writer.Write(buffer[0:rsize]) + size += int64(wsize) + if err != nil { + return size, err + } + if halt { + break + } + } + return size, err +} diff --git a/ociclient/copyctx_test.go b/ociclient/copyctx_test.go new file mode 100644 index 0000000..b6c991e --- /dev/null +++ b/ociclient/copyctx_test.go @@ -0,0 +1,28 @@ +package repocli + +import ( + "github.com/stretchr/testify/require" + + "bytes" + "context" + "fmt" + "math/rand" + "testing" +) + +func TestCopy(t *testing.T) { + srcsize := 1024 + 145 + srcdata := make([]byte, srcsize) + _, err := rand.Read(srcdata) + require.NoError(t, err) + + src := bytes.NewReader(srcdata) + dst := bytes.NewBuffer(nil) + + ctx := context.Background() + recsize, err := Copy(ctx, dst, src) + require.NoError(t, err) + + fmt.Printf("Size: %d %d\n", recsize, srcsize) + require.Equal(t, int64(srcsize), recsize) +} diff --git a/ociclient/delman.go b/ociclient/delman.go new file mode 100644 index 0000000..0050a8f --- /dev/null +++ b/ociclient/delman.go @@ -0,0 +1,38 @@ +package repocli + +import ( + "context" + "fmt" + "net/http" +) + +func (cli *Client) DeleteManifest(ctx context.Context, rawrepo, tag string) (bool, error) { + var err error + var exist bool + + ref, err := NewRepository(rawrepo) + if err != nil { + return exist, err + } + uri := ref.Manifest(tag) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return exist, err + } + req.Header.Set("User-Agent", cli.userAgent) + req.Header.Set("Accept", "*/*") + resp, err := cli.httpClient.Do(req) + if err != nil { + return exist, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return exist, err + } + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("Unxected response code %s", resp.Status) + return exist, err + } + exist = true + return exist, err +} diff --git a/ociclient/digest.go b/ociclient/digest.go new file mode 100644 index 0000000..2eab175 --- /dev/null +++ b/ociclient/digest.go @@ -0,0 +1,49 @@ +package repocli + +import ( + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "strings" +) + +func SHA256Digest(src []byte) string { + hasher := sha256.New() + hasher.Write(src) + sum := hasher.Sum(nil) + return "sha256:" + hex.EncodeToString(sum) +} + +func SHA512Digest(src []byte) string { + hasher := sha512.New() + hasher.Write(src) + sum := hasher.Sum(nil) + return "sha512:" + hex.EncodeToString(sum) +} + +const ( + Undefined int = iota + SHA256 + SHA512 +) + +func DigestType(digest string) int { + var err error + var typ int + digest = strings.ToLower(digest) + digest = strings.TrimPrefix(digest, "sha256:") + digest = strings.TrimPrefix(digest, "sha512:") + decoded, err := hex.DecodeString(digest) + if err != nil { + return Undefined + } + switch len(decoded) { + case 32: + typ = SHA256 + case 64: + typ = SHA512 + default: + typ = Undefined + } + return typ +} diff --git a/ociclient/getblob.go b/ociclient/getblob.go new file mode 100644 index 0000000..ee92aab --- /dev/null +++ b/ociclient/getblob.go @@ -0,0 +1,53 @@ +package repocli + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" +) + +func (cli *Client) GetBlob(ctx context.Context, rawrepo string, writer io.Writer, digest string) (bool, error) { + var err error + var exist bool + + ref, err := NewRepository(rawrepo) + if err != nil { + return exist, err + } + uri := ref.Blob(digest) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return exist, err + } + req.Header.Set("User-Agent", cli.userAgent) + req.Header.Set("Accept", "*/*") + resp, err := cli.httpClient.Do(req) + if err != nil { + return exist, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return exist, err + } + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("Unexpected response code %s", resp.Status) + return exist, err + } + contentLength := resp.Header.Get("Content-Length") + blobSize, err := strconv.ParseInt(contentLength, 10, 64) + if err != nil { + return exist, err + } + + recSize, err := Copy(ctx, writer, resp.Body) + if blobSize != recSize { + err := fmt.Errorf("Mismatch declared and actual body size, %d and %d", blobSize, recSize) + return exist, err + } + exist = true + return exist, err +} diff --git a/ociclient/getman.go b/ociclient/getman.go new file mode 100644 index 0000000..a51dc84 --- /dev/null +++ b/ociclient/getman.go @@ -0,0 +1,90 @@ +package repocli + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" +) + +func (cli *Client) GetManifest(ctx context.Context, rawrepo, tag string) (bool, string, []byte, error) { + var err error + var exist bool + var mime string + var man []byte + + ref, err := NewRepository(rawrepo) + if err != nil { + return exist, mime, man, err + } + uri := ref.Manifest(tag) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return exist, mime, man, err + } + req.Header.Set("User-Agent", cli.userAgent) + req.Header.Set("Accept", "*/*") + resp, err := cli.httpClient.Do(req) + if err != nil { + return exist, mime, man, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return exist, mime, man, err + } + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("Unxected response code %s", resp.Status) + return exist, mime, man, err + } + contentLength := resp.Header.Get("Content-Length") + if contentLength == "" { + err = errors.New("Empty Content-Length header") + return exist, mime, man, err + } + manSize, err := strconv.ParseInt(contentLength, 10, 64) + if err != nil { + return exist, mime, man, err + } + mime = resp.Header.Get("Content-Type") + if mime == "" { + err := fmt.Errorf("Empty MIME type declaration") + return exist, mime, man, err + } + buffer := bytes.NewBuffer(nil) + recSize, err := Copy(ctx, buffer, resp.Body) + if manSize != recSize { + err := fmt.Errorf("Mismatch declared and actual body size, %d and %d", manSize, recSize) + return exist, mime, man, err + } + man = buffer.Bytes() + + csum := resp.Header.Get("Docker-Content-Digest") + if csum == "" { + err := fmt.Errorf("Empty digest declaration") + return exist, mime, man, err + } + csum = strings.ToLower(csum) + switch DigestType(csum) { + case SHA256: + if csum != SHA256Digest(man) { + err := fmt.Errorf("Mismatch digest and actual declaration") + return exist, mime, man, err + } + case SHA512: + if csum != SHA256Digest(man) { + err := fmt.Errorf("Mismatch digest and actual declaration") + return exist, mime, man, err + } + default: + err := fmt.Errorf("Unknown digest type: %s", csum) + return exist, mime, man, err + } + + exist = true + return exist, mime, man, err +} diff --git a/ociclient/getupload.go b/ociclient/getupload.go new file mode 100644 index 0000000..e4c3cd6 --- /dev/null +++ b/ociclient/getupload.go @@ -0,0 +1,44 @@ +package repocli + +import ( + "context" + "fmt" + "net/http" +) + +func (cli *Client) GetUpload(ctx context.Context, rawrepo string) (string, error) { + var err error + var loc string + + ref, err := NewRepository(rawrepo) + if err != nil { + return loc, err + } + uri := ref.Upload() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return loc, err + } + req.Header.Set("User-Agent", cli.userAgent) + req.Header.Set("Accept", "*/*") + resp, err := cli.httpClient.Do(req) + if err != nil { + return loc, err + } + defer resp.Body.Close() + + fmt.Printf("=== %++v\n", resp.Header) + + if resp.StatusCode != http.StatusAccepted { + err := fmt.Errorf("Unxected response code %s", resp.Status) + return loc, err + } + loc = resp.Header.Get("Location") + + if loc == "" { + err := fmt.Errorf("Empty location declaration") + return loc, err + } + return loc, err +} diff --git a/ociclient/go.mod b/ociclient/go.mod new file mode 100644 index 0000000..e1c0b83 --- /dev/null +++ b/ociclient/go.mod @@ -0,0 +1,15 @@ +module client + +go 1.25.0 + +require ( + github.com/opencontainers/image-spec v1.1.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/ociclient/go.sum b/ociclient/go.sum new file mode 100644 index 0000000..e4da8cc --- /dev/null +++ b/ociclient/go.sum @@ -0,0 +1,16 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ociclient/jwt.go b/ociclient/jwt.go new file mode 100644 index 0000000..6c1b218 --- /dev/null +++ b/ociclient/jwt.go @@ -0,0 +1,63 @@ +package repocli + +import ( + "bytes" + "context" + "fmt" + "net/http" + //"strconv" + //"strings" + "encoding/json" + "time" +) + +type JWT struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + IssuedAt time.Time `json:"issued_at"` +} + +func (cli *Client) GetToken(ctx context.Context, uri string) (string, error) { + var err error + var token string + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return token, err + } + req.Header.Set("User-Agent", cli.userAgent) + req.Header.Set("Accept", "*/*") + resp, err := cli.httpClient.Do(req) + if err != nil { + return token, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return token, err + } + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("Unxected response code %s", resp.Status) + return token, err + } + mime := resp.Header.Get("Content-Type") + if mime != "application/json" { + err := fmt.Errorf("Empty MIME type declaration") + return token, err + } + buffer := bytes.NewBuffer(nil) + recSize, err := Copy(ctx, buffer, resp.Body) + if recSize == 0 { + err := fmt.Errorf("Zero actual body size") + return token, err + } + tokenJson := buffer.Bytes() + jwt := &JWT{} + err = json.Unmarshal(tokenJson, jwt) + if err != nil { + return token, err + } + token = jwt.Token + return token, err +} diff --git a/ociclient/manexist.go b/ociclient/manexist.go new file mode 100644 index 0000000..f4855ac --- /dev/null +++ b/ociclient/manexist.go @@ -0,0 +1,60 @@ +package repocli + +import ( + "context" + "fmt" + "net/http" + "strconv" +) + +func (cli *Client) ManifestExists(ctx context.Context, rawrepo, tag string) (bool, string, int64, string, error) { + var err error + var exist bool + var mime string + var size int64 + var csum string + + ref, err := NewRepository(rawrepo) + if err != nil { + return exist, mime, size, csum, err + } + uri := ref.Manifest(tag) + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, uri, nil) + if err != nil { + return exist, mime, size, csum, err + } + req.Header.Set("User-Agent", cli.userAgent) + req.Header.Set("Accept", "*/*") + resp, err := cli.httpClient.Do(req) + if err != nil { + return exist, mime, size, csum, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return exist, mime, size, csum, err + } + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("Unxected response code %s", resp.Status) + return exist, mime, size, csum, err + } + contentLength := resp.Header.Get("Content-Length") + size, err = strconv.ParseInt(contentLength, 10, 64) + if err != nil { + return exist, mime, size, csum, err + } + mime = resp.Header.Get("Content-Type") + if mime == "" { + err := fmt.Errorf("Empty MIME type declaration") + return exist, mime, size, csum, err + } + csum = resp.Header.Get("Docker-Content-Digest") + if csum == "" { + err := fmt.Errorf("Empty digest declaration") + return exist, mime, size, csum, err + } + + exist = true + return exist, mime, size, csum, err +} diff --git a/ociclient/mimetyp.go b/ociclient/mimetyp.go new file mode 100644 index 0000000..c5da23f --- /dev/null +++ b/ociclient/mimetyp.go @@ -0,0 +1,6 @@ +package repocli + +const ( + MediaTypeDDMLv2 = "application/vnd.docker.distribution.manifest.list.v2+json" + MediaTypeDDMv2 = "application/vnd.docker.distribution.manifest.v2+json" +) diff --git a/ociclient/pathupload.go b/ociclient/pathupload.go new file mode 100644 index 0000000..f1bc1b6 --- /dev/null +++ b/ociclient/pathupload.go @@ -0,0 +1,45 @@ +package repocli + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" +) + +func (cli *Client) PatchUpload(ctx context.Context, rawrepo string, src io.Reader, uploc string, size int64) (string, error) { + var err error + var ouloc string + + ref, err := NewRepository(rawrepo) + if err != nil { + return ouloc, err + } + uri, err := ref.Patch(uploc) + if err != nil { + return ouloc, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, uri, src) + if err != nil { + return ouloc, err + } + req.Header.Set("User-Agent", cli.userAgent) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-Length", strconv.FormatInt(size, 10)) + resp, err := cli.httpClient.Do(req) + if err != nil { + return ouloc, err + } + resp.Body.Close() + if resp.StatusCode != http.StatusAccepted { + err = fmt.Errorf("Upload not accepted, code %d", resp.StatusCode) + return ouloc, err + } + ouloc = resp.Header.Get("Location") + if ouloc == "" { + err := fmt.Errorf("Empty blob location declaration") + return ouloc, err + } + return ouloc, err +} diff --git a/ociclient/pullimage.go b/ociclient/pullimage.go new file mode 100644 index 0000000..c29e78b --- /dev/null +++ b/ociclient/pullimage.go @@ -0,0 +1,104 @@ +package repocli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +const ( + MediaTypeOIIv1 = "application/vnd.oci.image.index.v1+json" + MediatypeDDMLv2 = "application/vnd.docker.distribution.manifest.list.v2+json" + + MediatypeDDMv2 = "application/vnd.docker.distribution.manifest.v2+json" + MediaTypeOIMv1 = "application/vnd.oci.image.manifest.v1+json" +) + +type Downloader struct { + cli *Client +} + +func NewDownloader(client *Client) *Downloader { + return &Downloader{ + cli: client, + } +} + +func (down *Downloader) Pull(ctx context.Context, rawref, dir, os, arch string) error { + var err error + ref, err := NewReference(rawref) + if err != nil { + return err + } + rawrepo := ref.Repo() + tag := ref.Tag() + + exist, mime, man, err := down.cli.GetManifest(ctx, rawrepo, tag) + if err != nil { + return err + } + if !exist { + err = errors.New("Manifest not found") + return err + } + + if mime == MediaTypeOIIv1 || mime == MediatypeDDMLv2 { + var index ocispec.Index + err = json.Unmarshal(man, &index) + if err != nil { + return err + } + for _, descr := range index.Manifests { + if descr.Platform != nil { + cond := descr.Platform.Architecture == arch + cond = cond && descr.Platform.OS == os + if cond { + tag = descr.Digest.String() + } + } + } + } + fmt.Printf("Tag: %s\n", tag) + exist, mime, man, err = down.cli.GetManifest(ctx, rawrepo, tag) + if err != nil { + return err + } + fmt.Printf("Mime: %s\n", mime) + if !exist { + err = errors.New("Manifest not found") + return err + } + if mime != MediaTypeOIMv1 && mime != MediatypeDDMv2 { + err = errors.New("Unknown manifest media type") + return err + } + var manifest ocispec.Manifest + err = json.Unmarshal(man, &manifest) + if err != nil { + return err + } + "oci-layout" + "index.json" + + layers := make([]ocispec.Descriptor, 0) + layers = append(layers, manifest.Config) + layers = append(layers, manifest.Layers...) + for _, layer := range layers { + digest := layer.Digest.String() + exist, err := down.cli.GetBlob(ctx, rawrepo, io.Discard, digest) + if err != nil { + return err + } + if !exist { + err = errors.New("Layer not found") + return err + } + fmt.Printf("Layer type: %s\n", layer.MediaType) + + } + return err +} diff --git a/ociclient/pullimage_test.go b/ociclient/pullimage_test.go new file mode 100644 index 0000000..41aeb1f --- /dev/null +++ b/ociclient/pullimage_test.go @@ -0,0 +1,25 @@ +package repocli + +import ( + "github.com/stretchr/testify/require" + + //"fmt" + "context" + "testing" + "time" +) + +func TestPullImage(t *testing.T) { + + ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) + cli := NewClientWithTransport(nil, nil) + require.NotNil(t, cli) + + down := NewDownloader(cli) + require.NotNil(t, down) + + rawref := "mirror.gcr.io/alpine:3.20.0" + destdir := "qwert" + err := down.Pull(ctx, rawref, destdir, "linux", "amd64") + require.NoError(t, err) +} diff --git a/ociclient/putman.go b/ociclient/putman.go new file mode 100644 index 0000000..e199cc2 --- /dev/null +++ b/ociclient/putman.go @@ -0,0 +1,42 @@ +package repocli + +import ( + "bytes" + "context" + "fmt" + "net/http" +) + +func (cli *Client) PutManifest(ctx context.Context, rawrepo, tag string, man []byte, mime string) error { + var err error + + ref, err := NewRepository(rawrepo) + if err != nil { + return err + } + uri := ref.Manifest(tag) + + buffer := bytes.NewBuffer(man) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, uri, buffer) + if err != nil { + return err + } + req.Header.Set("User-Agent", cli.userAgent) + req.Header.Set("Docker-Content-Digest", SHA256Digest(man)) + req.Header.Set("Content-Type", mime) + resp, err := cli.httpClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode != http.StatusAccepted { + err = fmt.Errorf("Manifest not accepted, code %d", resp.StatusCode) + return err + } + loc := resp.Header.Get("Location") + if loc == "" { + err := fmt.Errorf("Empty manifest location declaration") + return err + } + return err +} diff --git a/ociclient/putupload.go b/ociclient/putupload.go new file mode 100644 index 0000000..31065ab --- /dev/null +++ b/ociclient/putupload.go @@ -0,0 +1,45 @@ +package repocli + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" +) + +func (cli *Client) PutUpload(ctx context.Context, rawrepo string, src io.Reader, uploc, digest string, size int64) (string, error) { + var err error + var bloc string + + ref, err := NewRepository(rawrepo) + if err != nil { + return bloc, err + } + uri, err := ref.Put(uploc, digest) + if err != nil { + return bloc, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, uri, src) + if err != nil { + return bloc, err + } + req.Header.Set("User-Agent", cli.userAgent) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-Length", strconv.FormatInt(size, 10)) + resp, err := cli.httpClient.Do(req) + if err != nil { + return bloc, err + } + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + err = fmt.Errorf("Upload not accepted, code %d", resp.StatusCode) + return bloc, err + } + bloc = resp.Header.Get("Location") + if bloc == "" { + err := fmt.Errorf("Empty blob location declaration") + return bloc, err + } + return bloc, err +} diff --git a/ociclient/reference.go b/ociclient/reference.go new file mode 100644 index 0000000..a08a749 --- /dev/null +++ b/ociclient/reference.go @@ -0,0 +1,56 @@ +package repocli + +import ( + "errors" + "net/url" + "path" + "strings" +) + +type Reference struct { + urlobj *url.URL + user, pass string + base, tag string +} + +func NewReference(rawref string) (*Reference, error) { + ref := &Reference{} + if !strings.Contains(rawref, "://") { + rawref = "https://" + rawref + } + urlobj, err := url.Parse(rawref) + if err != nil { + return ref, err + } + if urlobj.User != nil { + ref.user = urlobj.User.Username() + ref.pass, _ = urlobj.User.Password() + urlobj.User = nil + } + ref.urlobj = urlobj + + repotag := strings.SplitN(ref.urlobj.Path, ":", 2) + if len(repotag) != 2 { + err = errors.New("Incorrect reference format") + return ref, err + } + ref.base = repotag[0] + ref.tag = repotag[1] + + ref.urlobj.Path = "/" + ref.urlobj = urlobj + + return ref, err +} + +func (ref *Reference) String() string { + return path.Join(ref.urlobj.Host, ref.base+":"+ref.tag) +} + +func (ref *Reference) Repo() string { + return path.Join(ref.urlobj.Host, ref.base) +} + +func (ref *Reference) Tag() string { + return ref.tag +} diff --git a/ociclient/repo.go b/ociclient/repo.go new file mode 100644 index 0000000..17d4cfa --- /dev/null +++ b/ociclient/repo.go @@ -0,0 +1,100 @@ +package repocli + +import ( + "net/url" + "strings" +) + +type Repository struct { + urlobj *url.URL + user, pass string + base string +} + +func NewRepository(rawrepo string) (*Repository, error) { + repo := &Repository{} + if !strings.Contains(rawrepo, "://") { + rawrepo = "https://" + rawrepo + } + urlobj, err := url.Parse(rawrepo) + if err != nil { + return repo, err + } + if urlobj.User != nil { + repo.user = urlobj.User.Username() + repo.pass, _ = urlobj.User.Password() + urlobj.User = nil + } + repo.urlobj = urlobj + repo.base = repo.urlobj.Path + repo.urlobj.Path = "/" + repo.urlobj = urlobj + + return repo, err +} + +func (repo *Repository) String() string { + curl := repo.urlobj.JoinPath(repo.base) + return curl.String() +} + +func (repo *Repository) Manifest(tag string) string { + curl := repo.urlobj.JoinPath("/v2", repo.base, "/manifests", tag) + return curl.String() +} + +func (repo *Repository) Blob(digest string) string { + curl := repo.urlobj.JoinPath("/v2", repo.base, "/blobs", digest) + return curl.String() +} + +func (repo *Repository) Upload() string { + curl := repo.urlobj.JoinPath("/v2", repo.base, "/blobs/uploads/") + return curl.String() +} + +func (repo *Repository) Patch(loc string) (string, error) { + var curl *url.URL + var out string + var err error + if isUUID(loc) { + curl = repo.urlobj.JoinPath("/v2/", repo.base, "/blobs/uploads/", loc) + return curl.String(), nil + } + if strings.Contains(loc, "://") { + curl, err = url.Parse(loc) + if err != nil { + return out, err + } + } else { + curl = repo.urlobj.JoinPath(loc) + } + out = curl.String() + return out, err +} + +func (repo *Repository) Put(loc, digest string) (string, error) { + var curl *url.URL + var out string + var err error + + if isUUID(loc) { + curl = repo.urlobj.JoinPath("/v2/", repo.base, "/blobs/uploads/", loc) + } else if strings.Contains(loc, "://") { + curl, err = url.Parse(loc) + if err != nil { + return out, err + } + } else { + curl = repo.urlobj.JoinPath(loc) + } + query := curl.Query() + query.Set("digest", digest) + curl.RawQuery = query.Encode() + out = curl.String() + return out, err +} + +func (repo *Repository) Userinfo() (string, string) { + return repo.user, repo.pass +} diff --git a/ociclient/repo_test.go b/ociclient/repo_test.go new file mode 100644 index 0000000..9d930a3 --- /dev/null +++ b/ociclient/repo_test.go @@ -0,0 +1,25 @@ +package repocli + +import ( + "github.com/stretchr/testify/require" + + "fmt" + "testing" +) + +func xxxTestResrerer(t *testing.T) { + ref, err := NewRepository("registry.example.com/lib/alpine") + require.NoError(t, err) + + fmt.Printf("Manifest:\t%s\n", ref.Manifest("3.30.0")) + + digest := SHA256Digest([]byte("qwerty")) + fmt.Printf("Blob:\t\t%s\n", ref.Blob(digest)) + fmt.Printf("POST:\t\t%s\n", ref.Upload()) + uuid := "8be4df61-93ca-11d2-aa0d-00e098032b8c" + rawurl, err := ref.Patch(uuid) + require.NoError(t, err) + fmt.Printf("PATH:\t\t%s\n", rawurl) + rawurl, err = ref.Put(uuid, digest) + fmt.Printf("PUT:\t\t%s\n", rawurl) +} diff --git a/ociclient/uuid.go b/ociclient/uuid.go new file mode 100644 index 0000000..d79621f --- /dev/null +++ b/ociclient/uuid.go @@ -0,0 +1,12 @@ +package repocli + +import ( + "regexp" +) + +const uuidRegex = `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$` + +func isUUID(src string) bool { + re := regexp.MustCompile(uuidRegex) + return re.MatchString(src) +}