diff --git a/jwt.go b/jwt.go new file mode 100644 index 0000000..6c1b218 --- /dev/null +++ b/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/pullimage.go b/pullimage.go new file mode 100644 index 0000000..c29e78b --- /dev/null +++ b/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/pullimage_test.go b/pullimage_test.go new file mode 100644 index 0000000..41aeb1f --- /dev/null +++ b/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/reference.go b/reference.go new file mode 100644 index 0000000..a08a749 --- /dev/null +++ b/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/repo.go b/repo.go new file mode 100644 index 0000000..17d4cfa --- /dev/null +++ b/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/repo_test.go b/repo_test.go new file mode 100644 index 0000000..9d930a3 --- /dev/null +++ b/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/uuid.go b/uuid.go new file mode 100644 index 0000000..d79621f --- /dev/null +++ b/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) +}