Skip to content

Commit

Permalink
feat: add depot push
Browse files Browse the repository at this point in the history
Signed-off-by: Chris Goller <[email protected]>
  • Loading branch information
goller committed Nov 30, 2023
1 parent 4f3da20 commit 6d797bc
Show file tree
Hide file tree
Showing 9 changed files with 867 additions and 6 deletions.
98 changes: 98 additions & 0 deletions pkg/cmd/push/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package push

import (
"context"
"fmt"
"net/http"
"strings"

"github.com/containerd/containerd/reference"
"github.com/containerd/containerd/remotes/docker"
"github.com/containerd/containerd/remotes/docker/auth"
depotapi "github.com/depot/cli/pkg/api"
"github.com/docker/cli/cli/command"
configtypes "github.com/docker/cli/cli/config/types"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
)

func GetAuthToken(ctx context.Context, dockerCli command.Cli, parsedTag *ParsedTag, manifest ocispecs.Descriptor) (*Token, error) {
authConfig, err := GetAuthConfig(dockerCli, parsedTag.Host)
if err != nil {
return nil, err
}

push := true
scope, err := docker.RepositoryScope(parsedTag.Refspec, push)
if err != nil {
return nil, err
}

challenge, err := AuthKind(ctx, parsedTag.Refspec, manifest)
if err != nil {
return nil, err
}

return FetchToken(ctx, authConfig, challenge, []string{scope})
}

func GetAuthConfig(dockerCli command.Cli, host string) (*configtypes.AuthConfig, error) {
if host == "registry-1.docker.io" {
host = "https://index.docker.io/v1/"
}

config, err := dockerCli.ConfigFile().GetAuthConfig(host)
if err != nil {
return nil, err
}
return &config, nil
}

// AuthKind tries to do a HEAD request to the manifest to try to get the WWW-Authenticate header.
// If HEAD is not supported, it will try to get a GET. Apparently, this is for older registries.
func AuthKind(ctx context.Context, refspec reference.Spec, manifest ocispecs.Descriptor) (*auth.Challenge, error) {
// Reversing the refspec's path.Join behavior.
i := strings.Index(refspec.Locator, "/")
host, repository := refspec.Locator[:i], refspec.Locator[i+1:]
if host == "docker.io" {
host = "registry-1.docker.io"
}

url := fmt.Sprintf("https://%s/v2/%s/manifests/%s", host, repository, refspec.Object)
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return nil, err
}
req.Header.Add("Accept", manifest.MediaType)
req.Header.Add("Accept", `*/*`)
req.Header.Set("User-Agent", depotapi.Agent())

// Helper function allowing the HTTP method to change because some registries
// use GET rather than HEAD (according to an old comment).
return checkAuthKind(ctx, req)
}

func checkAuthKind(ctx context.Context, req *http.Request) (*auth.Challenge, error) {
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
_ = res.Body.Close()
switch res.StatusCode {
case http.StatusOK:
return nil, nil
case http.StatusUnauthorized:
challenges := auth.ParseAuthHeader(res.Header)
if len(challenges) == 0 {
return nil, nil
}
return &challenges[0], nil
case http.StatusMethodNotAllowed:
// We have a callback here to allow us to retry the request with a `GET`if the registry doesn't support `HEAD`.
req.Method = http.MethodGet
return checkAuthKind(ctx, req)
case http.StatusRequestTimeout, http.StatusTooManyRequests:
// TODO: backoff and retry.
}

return nil, fmt.Errorf("unexpected status code: %s", res.Status)
}
54 changes: 54 additions & 0 deletions pkg/cmd/push/blobs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package push

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"

"github.com/opencontainers/go-digest"
)

type BlobRequest struct {
ParsedTag *ParsedTag
RegistryToken *Token
BuildID string
Digest digest.Digest
}

func PushBlob(ctx context.Context, depotToken string, req *BlobRequest) error {
pushRequest := struct {
RegistryHost string `json:"registryHost"`
RepositoryNamespace string `json:"repositoryNamespace"`
RegistryToken string `json:"registryToken"`
TokenScheme string `json:"tokenScheme"`
}{
RegistryHost: req.ParsedTag.Host,
RepositoryNamespace: req.ParsedTag.Path,
RegistryToken: req.RegistryToken.Token,
TokenScheme: req.RegistryToken.Scheme,
}
buf, _ := json.MarshalIndent(pushRequest, "", " ")

url := fmt.Sprintf("https://blob.depot.dev/blobs/%s/%s", req.BuildID, req.Digest.String())
pushReq, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(buf)))
if err != nil {
return err
}
pushReq.Header.Add("Authorization", "Bearer "+depotToken)
pushReq.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(pushReq)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(resp.Body)
err := fmt.Errorf("unexpected status code: %d %s", resp.StatusCode, string(body))
return err
}

return nil
}
38 changes: 38 additions & 0 deletions pkg/cmd/push/image.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package push

import (
"github.com/containerd/containerd/reference"
"github.com/containerd/containerd/remotes/docker"
ref "github.com/distribution/reference"
)

type ParsedTag struct {
Host string
Path string
Refspec reference.Spec
Tag string
}

func ParseTag(tag string) (*ParsedTag, error) {
named, err := ref.ParseNormalizedNamed(tag)
if err != nil {
return nil, err
}

domain := ref.Domain(named)
path := ref.Path(named)
named = ref.TagNameOnly(named)
var imageTag string
if r, ok := named.(ref.Tagged); ok {
imageTag = r.Tag()
}

host, _ := docker.DefaultHost(domain)

refspec, err := reference.Parse(named.String())
if err != nil {
return nil, err
}

return &ParsedTag{Host: host, Path: path, Refspec: refspec, Tag: imageTag}, nil
}
181 changes: 181 additions & 0 deletions pkg/cmd/push/manifests.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package push

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"

"github.com/bufbuild/connect-go"
"github.com/containerd/containerd/errdefs"
"github.com/containerd/containerd/images"
"github.com/containerd/containerd/reference"
"github.com/containerd/containerd/remotes"
"github.com/containerd/containerd/remotes/docker"
depotapi "github.com/depot/cli/pkg/api"
"github.com/depot/cli/pkg/progress"
cliv1 "github.com/depot/cli/pkg/proto/depot/cli/v1"
"github.com/opencontainers/go-digest"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
)

func PushManifest(ctx context.Context, registryToken string, refspec reference.Spec, tag string, manifest ocispecs.Descriptor, manifestBytes []byte) error {
// Reversing the refspec's path.Join behavior.
i := strings.Index(refspec.Locator, "/")
host, repository := refspec.Locator[:i], refspec.Locator[i+1:]
if host == "docker.io" {
host = "registry-1.docker.io"
}

url := fmt.Sprintf("https://%s/v2/%s/manifests/%s", host, repository, tag)

req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(manifestBytes))
if err != nil {
return err
}
req.Header.Set("User-Agent", depotapi.Agent())
req.Header.Set("Content-Type", manifest.MediaType)
// TODO:
req.Header.Set("Authorization", "Bearer "+registryToken)

res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode/100 == 2 {
return nil
}

body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
return fmt.Errorf("unexpected status code: %s %s", res.Status, string(body))
}

type ImageDescriptors struct {
Indices []ocispecs.Descriptor `json:"indices,omitempty"`
Manifests []ocispecs.Descriptor `json:"manifests,omitempty"`
Configs []ocispecs.Descriptor `json:"configs,omitempty"`
Layers []ocispecs.Descriptor `json:"layers,omitempty"`

IndexBytes map[digest.Digest][]byte `json:"indexBytes,omitempty"`
ManifestBytes map[digest.Digest][]byte `json:"manifestBytes,omitempty"`
}

// GetImageDescriptors returns back all the descriptors for an image.
func GetImageDescriptors(ctx context.Context, token, buildID string, logger progress.StartLogDetailFunc) (*ImageDescriptors, error) {
// Download location and credentials of ephemeral image save.
client := depotapi.NewBuildClient()
req := &cliv1.GetPullInfoRequest{BuildId: buildID}
res, err := client.GetPullInfo(ctx, depotapi.WithAuthentication(connect.NewRequest(req), token))
if err != nil {
return nil, err
}

username, password, ref := res.Msg.Username, res.Msg.Password, res.Msg.Reference

authorizer := &Authorizer{Username: username, Password: password}
hosts := docker.ConfigureDefaultRegistries(docker.WithAuthorizer(authorizer))

headers := http.Header{}
headers.Set("User-Agent", depotapi.Agent())
resolver := docker.NewResolver(docker.ResolverOptions{
Hosts: hosts,
Headers: headers,
})

fin := logger(fmt.Sprintf("Resolving %s", ref))
name, desc, err := resolver.Resolve(ctx, ref)
fin()
if err != nil {
return nil, err
}

fin = logger(fmt.Sprintf("Fetching %s", name))
fetcher, err := resolver.Fetcher(ctx, name)
fin()
if err != nil {
return nil, err
}

stack := []ocispecs.Descriptor{desc}
descs := ImageDescriptors{
IndexBytes: map[digest.Digest][]byte{},
ManifestBytes: map[digest.Digest][]byte{},
}

for len(stack) > 0 {
desc, stack = stack[len(stack)-1], stack[:len(stack)-1]

// Only download unique descriptors.
if _, ok := descs.IndexBytes[desc.Digest]; ok {
continue
}
if _, ok := descs.ManifestBytes[desc.Digest]; ok {
continue
}

fin = logger(fmt.Sprintf("Fetching manifest %s", desc.Digest.String()))
buf, err := fetch(ctx, fetcher, desc)
fin()
if err != nil {
return nil, err
}

if images.IsIndexType(desc.MediaType) {
descs.Indices = append(descs.Indices, desc)
var index ocispecs.Index
if err := json.Unmarshal(buf, &index); err != nil {
return nil, err
}

descs.IndexBytes[desc.Digest] = buf

for _, m := range index.Manifests {
if m.Digest != "" {
stack = append(stack, m)
}
}
} else if images.IsManifestType(desc.MediaType) {
descs.Manifests = append(descs.Manifests, desc)
var manifest ocispecs.Manifest
if err := json.Unmarshal(buf, &manifest); err != nil {
return nil, err
}

descs.ManifestBytes[desc.Digest] = buf

descs.Configs = append(descs.Configs, manifest.Config)
descs.Layers = append(descs.Layers, manifest.Layers...)
}
}

return &descs, nil
}

func fetch(ctx context.Context, fetcher remotes.Fetcher, desc ocispecs.Descriptor) ([]byte, error) {
r, err := fetcher.Fetch(ctx, desc)
if err != nil {
return nil, err
}
defer func() { _ = r.Close() }()
return io.ReadAll(r)
}

type Authorizer struct {
Username string
Password string
}

func (a *Authorizer) Authorize(ctx context.Context, req *http.Request) error {
req.SetBasicAuth(a.Username, a.Password)
return nil
}
func (a *Authorizer) AddResponses(ctx context.Context, responses []*http.Response) error {
return errdefs.ErrNotImplemented
}
Loading

0 comments on commit 6d797bc

Please sign in to comment.