From 6d797bc1b9b88a04bc2f629b04159e504d938aa7 Mon Sep 17 00:00:00 2001 From: Chris Goller Date: Sat, 25 Nov 2023 11:47:48 -0600 Subject: [PATCH] feat: add depot push Signed-off-by: Chris Goller --- pkg/cmd/push/auth.go | 98 ++++++++++++++++++ pkg/cmd/push/blobs.go | 54 ++++++++++ pkg/cmd/push/image.go | 38 +++++++ pkg/cmd/push/manifests.go | 181 +++++++++++++++++++++++++++++++++ pkg/cmd/push/printer.go | 96 ++++++++++++++++++ pkg/cmd/push/push.go | 203 ++++++++++++++++++++++++++++++++++++++ pkg/cmd/push/token.go | 141 ++++++++++++++++++++++++++ pkg/cmd/root/root.go | 2 + pkg/progress/progress.go | 60 +++++++++-- 9 files changed, 867 insertions(+), 6 deletions(-) create mode 100644 pkg/cmd/push/auth.go create mode 100644 pkg/cmd/push/blobs.go create mode 100644 pkg/cmd/push/image.go create mode 100644 pkg/cmd/push/manifests.go create mode 100644 pkg/cmd/push/printer.go create mode 100644 pkg/cmd/push/push.go create mode 100644 pkg/cmd/push/token.go diff --git a/pkg/cmd/push/auth.go b/pkg/cmd/push/auth.go new file mode 100644 index 00000000..a4ab601e --- /dev/null +++ b/pkg/cmd/push/auth.go @@ -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) +} diff --git a/pkg/cmd/push/blobs.go b/pkg/cmd/push/blobs.go new file mode 100644 index 00000000..e1e16c86 --- /dev/null +++ b/pkg/cmd/push/blobs.go @@ -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 +} diff --git a/pkg/cmd/push/image.go b/pkg/cmd/push/image.go new file mode 100644 index 00000000..c7202fc9 --- /dev/null +++ b/pkg/cmd/push/image.go @@ -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 +} diff --git a/pkg/cmd/push/manifests.go b/pkg/cmd/push/manifests.go new file mode 100644 index 00000000..1f0571bd --- /dev/null +++ b/pkg/cmd/push/manifests.go @@ -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 +} diff --git a/pkg/cmd/push/printer.go b/pkg/cmd/push/printer.go new file mode 100644 index 00000000..43e3de5a --- /dev/null +++ b/pkg/cmd/push/printer.go @@ -0,0 +1,96 @@ +package push + +import ( + "context" + "io" + "os" + "sync" + + "github.com/containerd/console" + "github.com/docker/buildx/util/logutil" + prog "github.com/docker/buildx/util/progress" + "github.com/moby/buildkit/client" + "github.com/moby/buildkit/util/progress/progressui" + "github.com/opencontainers/go-digest" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +// Specialized printer as the default buildkit one has a hard-coded display phrase, "Building." +type Printer struct { + status chan *client.SolveStatus + done <-chan struct{} + err error + warnings []client.VertexWarning + logMu sync.Mutex + logSourceMap map[digest.Digest]interface{} +} + +func (p *Printer) Wait() error { close(p.status); <-p.done; return p.err } +func (p *Printer) Write(s *client.SolveStatus) { p.status <- s } +func (p *Printer) Warnings() []client.VertexWarning { return p.warnings } + +func (p *Printer) ValidateLogSource(dgst digest.Digest, v interface{}) bool { + p.logMu.Lock() + defer p.logMu.Unlock() + src, ok := p.logSourceMap[dgst] + if ok { + if src == v { + return true + } + } else { + p.logSourceMap[dgst] = v + return true + } + return false +} + +func (p *Printer) ClearLogSource(v interface{}) { + p.logMu.Lock() + defer p.logMu.Unlock() + for d := range p.logSourceMap { + if p.logSourceMap[d] == v { + delete(p.logSourceMap, d) + } + } +} + +func NewPrinter(ctx context.Context, displayPhrase string, w io.Writer, out console.File, mode string) (*Printer, error) { + statusCh := make(chan *client.SolveStatus) + doneCh := make(chan struct{}) + + pw := &Printer{ + status: statusCh, + done: doneCh, + logSourceMap: map[digest.Digest]interface{}{}, + } + + if v := os.Getenv("BUILDKIT_PROGRESS"); v != "" && mode == prog.PrinterModeAuto { + mode = v + } + + var c console.Console + switch mode { + case prog.PrinterModeQuiet: + w = io.Discard + case prog.PrinterModeAuto, prog.PrinterModeTty: + if cons, err := console.ConsoleFromFile(out); err == nil { + c = cons + } else { + if mode == prog.PrinterModeTty { + return nil, errors.Wrap(err, "failed to get console") + } + } + } + + go func() { + resumeLogs := logutil.Pause(logrus.StandardLogger()) + // not using shared context to not disrupt display but let is finish reporting errors + // DEPOT: allowed displayPhrase to be overridden. + pw.warnings, pw.err = progressui.DisplaySolveStatus(ctx, displayPhrase, c, w, statusCh) + resumeLogs() + close(doneCh) + }() + + return pw, nil +} diff --git a/pkg/cmd/push/push.go b/pkg/cmd/push/push.go new file mode 100644 index 00000000..33e3f8fa --- /dev/null +++ b/pkg/cmd/push/push.go @@ -0,0 +1,203 @@ +package push + +import ( + "context" + "fmt" + "os" + + depotapi "github.com/depot/cli/pkg/api" + "github.com/depot/cli/pkg/ci" + "github.com/depot/cli/pkg/helpers" + "github.com/depot/cli/pkg/progress" + prog "github.com/docker/buildx/util/progress" + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +func NewCmdPush(dockerCli command.Cli) *cobra.Command { + var ( + token string + projectID string + buildID string + progressFmt string + tag string + ) + + cmd := &cobra.Command{ + Use: "push [flags] [tag] [buildID]", + Short: "Push a project's build to a registry from the Depot registry", + Args: cli.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + tag = args[0] + buildID = args[1] + + _, isCI := ci.Provider() + if progressFmt == prog.PrinterModeAuto && isCI { + progressFmt = prog.PrinterModePlain + } + + ctx := cmd.Context() + + token, err := helpers.ResolveToken(ctx, token) + if err != nil { + return err + } + + if token == "" { + return fmt.Errorf("missing API token, please run `depot login`") + } + + projectID = helpers.ResolveProjectID(projectID) + projectID, err = selectProjectID(ctx, token, projectID) + if err != nil { + return err + } + + if buildID == "" { + buildID, err = selectBuildID(ctx, token, projectID) + if err != nil { + return err + } + } + + printerCtx, cancel := context.WithCancel(ctx) + displayPhrase := "Depot Push" + printer, err := NewPrinter(printerCtx, displayPhrase, os.Stderr, os.Stderr, progressFmt) + if err != nil { + cancel() + return err + } + + reporter, done, err := progress.NewProgress(printerCtx, buildID, token, printer) + if err != nil { + cancel() + return err + } + + defer func() { + _ = reporter.Wait() + cancel() + done() + }() + + logger, finishReporting := reporter.StartLog(fmt.Sprintf("[depot] Pushing build %s as %s", buildID, tag)) + + buildDescriptors, err := GetImageDescriptors(ctx, token, buildID, logger) + if err != nil { + finishReporting(err) + return err + } + + parsedTag, err := ParseTag(tag) + if err != nil { + finishReporting(err) + return err + } + + fin := logger("Fetching auth token") + manifest := buildDescriptors.Manifests[0] + registryToken, err := GetAuthToken(ctx, dockerCli, parsedTag, manifest) + fin() + if err != nil { + finishReporting(err) + return err + } + + blobs := append(buildDescriptors.Layers, buildDescriptors.Configs...) + for _, blob := range blobs { + fin = logger(fmt.Sprintf("Pushing blob %s", blob.Digest.String())) + + req := &BlobRequest{ + ParsedTag: parsedTag, + RegistryToken: registryToken, + BuildID: buildID, + Digest: blob.Digest, + } + err := PushBlob(ctx, token, req) + fin() + if err != nil { + finishReporting(err) + return err + } + } + + for _, manifest := range buildDescriptors.Manifests { + fin = logger(fmt.Sprintf("Pushing manifest %s", manifest.Digest.String())) + + buf := buildDescriptors.ManifestBytes[manifest.Digest] + err := PushManifest(ctx, registryToken.Token, parsedTag.Refspec, manifest.Digest.String(), manifest, buf) + fin() + if err != nil { + finishReporting(err) + return err + } + } + + for _, index := range buildDescriptors.Indices { + fin = logger(fmt.Sprintf("Pushing index %s", index.Digest.String())) + + buf := buildDescriptors.IndexBytes[index.Digest] + err := PushManifest(ctx, registryToken.Token, parsedTag.Refspec, parsedTag.Tag, index, buf) + fin() + if err != nil { + finishReporting(err) + return err + } + } + + finishReporting(nil) + return nil + }, + } + + cmd.Flags().StringVar(&projectID, "project", "", "Depot project ID") + cmd.Flags().StringVar(&token, "token", "", "Depot API token") + cmd.Flags().StringVar(&progressFmt, "progress", "auto", `Set type of progress output ("auto", "plain", "tty", "quiet")`) + + return cmd +} +func selectProjectID(ctx context.Context, token, projectID string) (string, error) { + var ( + selectedProject *helpers.SelectedProject + err error + ) + + if projectID == "" { // No locally saved depot.json. + selectedProject, err = helpers.OnboardProject(ctx, token) + if err != nil { + return "", err + } + } else { + selectedProject, err = helpers.ProjectExists(ctx, token, projectID) + if err != nil { + return "", err + } + } + return selectedProject.ID, nil +} + +func selectBuildID(ctx context.Context, token, projectID string) (string, error) { + client := depotapi.NewBuildClient() + + if !helpers.IsTerminal() { + depotBuilds, err := helpers.Builds(ctx, token, projectID, client) + if err != nil { + return "", err + } + _ = depotBuilds.WriteCSV() + return "", errors.New("build ID must be specified") + } + + buildID, err := helpers.SelectBuildID(ctx, token, projectID, client) + if err != nil { + return "", err + } + + if buildID == "" { + return "", errors.New("build ID must be specified") + } + + return buildID, nil +} diff --git a/pkg/cmd/push/token.go b/pkg/cmd/push/token.go new file mode 100644 index 00000000..ed414abe --- /dev/null +++ b/pkg/cmd/push/token.go @@ -0,0 +1,141 @@ +package push + +import ( + "context" + "net/http" + + "github.com/containerd/containerd/remotes/docker/auth" + remoteerrors "github.com/containerd/containerd/remotes/errors" + configtypes "github.com/docker/cli/cli/config/types" + "github.com/pkg/errors" +) + +type Token struct { + Token string + Scheme string +} + +// FetchToken gets a token for the registry using the realm and scoped by scopes. +// This token is used as the bearer token for the registry. +func FetchToken(ctx context.Context, config *configtypes.AuthConfig, challenge *auth.Challenge, scopes []string) (*Token, error) { + // This checks for static token in config.json. + token := RegistryToken(config) + if token != nil { + return token, nil + } + + var ( + username string + secret string + ) + if config.IdentityToken != "" { + secret = config.IdentityToken + } else { + username = config.Username + secret = config.Password + } + + if secret == "" { + return GetAnonymousToken(ctx, username, challenge, scopes) + } + + return GetOAuthToken(ctx, username, secret, challenge, scopes) +} + +// RegistryToken is a static token for the registry that is defined in the config.json. +func RegistryToken(authConfig *configtypes.AuthConfig) *Token { + if authConfig.RegistryToken == "" { + return nil + } + + return &Token{ + Token: authConfig.RegistryToken, + // As far as I can tell, this is supposed to be bearer. + Scheme: "bearer", + } +} + +// GetAnonymousToken gets a token when the registry does not require authentication. +// I'm not sure when this is used. +func GetAnonymousToken(ctx context.Context, username string, challenge *auth.Challenge, scopes []string) (*Token, error) { + realm := challenge.Parameters["realm"] + service := challenge.Parameters["service"] + + tokenOptions := auth.TokenOptions{ + Realm: realm, + Service: service, + Scopes: scopes, + Username: username, + } + client := http.DefaultClient + var headers http.Header + // TODO: handle nil fetchtoken + res, err := auth.FetchToken(ctx, client, headers, tokenOptions) + if err != nil { + return nil, err + } + + return &Token{ + Token: res.Token, + Scheme: scheme(challenge.Scheme), + }, nil +} + +// GetOAuthToken gets a token when the registry requires authentication. +func GetOAuthToken(ctx context.Context, username, secret string, challenge *auth.Challenge, scopes []string) (*Token, error) { + realm := challenge.Parameters["realm"] + service := challenge.Parameters["service"] + + tokenOptions := auth.TokenOptions{ + Realm: realm, + Service: service, + Scopes: scopes, + Username: username, + Secret: secret, + } + + client := http.DefaultClient + var headers http.Header + // TODO: handle nil fetchtoken + res, err := auth.FetchTokenWithOAuth(ctx, client, headers, "depot-client", tokenOptions) + if err == nil { + return &Token{ + Token: res.AccessToken, + Scheme: scheme(challenge.Scheme), + }, nil + } + + var errStatus remoteerrors.ErrUnexpectedStatus + if !errors.As(err, &errStatus) { + return nil, err + } + + // Registries without support for POST may return 404 for POST /v2/token. + // As of September 2017, GCR is known to return 404. + // As of February 2018, JFrog Artifactory is known to return 401. + // As of January 2022, ACR is known to return 400 + // + // DEPOT: this is the fallback to GET /v2/token. + getRes, err := auth.FetchToken(ctx, client, headers, tokenOptions) + if err != nil { + return nil, err + } + + return &Token{ + Token: getRes.Token, + Scheme: scheme(challenge.Scheme), + }, nil +} + +func scheme(scheme auth.AuthenticationScheme) string { + switch scheme { + case auth.BasicAuth: + return "basic" + case auth.DigestAuth: + return "digest" + case auth.BearerAuth: + return "bearer" + default: + return "" + } +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 0cf14d9d..b58375c5 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -15,6 +15,7 @@ import ( loginCmd "github.com/depot/cli/pkg/cmd/login" logout "github.com/depot/cli/pkg/cmd/logout" "github.com/depot/cli/pkg/cmd/pull" + "github.com/depot/cli/pkg/cmd/push" "github.com/depot/cli/pkg/cmd/registry" versionCmd "github.com/depot/cli/pkg/cmd/version" "github.com/depot/cli/pkg/config" @@ -55,6 +56,7 @@ func NewCmdRoot(version, buildDate string) *cobra.Command { cmd.AddCommand(loginCmd.NewCmdLogin()) cmd.AddCommand(logout.NewCmdLogout()) cmd.AddCommand(pull.NewCmdPull(dockerCli)) + cmd.AddCommand(push.NewCmdPush(dockerCli)) cmd.AddCommand(versionCmd.NewCmdVersion(version, buildDate)) cmd.AddCommand(dockerCmd.NewCmdConfigureDocker(dockerCli)) cmd.AddCommand(registry.NewCmdRegistry()) diff --git a/pkg/progress/progress.go b/pkg/progress/progress.go index 006ba0cc..5c29bc8f 100644 --- a/pkg/progress/progress.go +++ b/pkg/progress/progress.go @@ -18,6 +18,14 @@ import ( var _ progress.Writer = (*Progress)(nil) +type BuildKitProgressWriter interface { + Write(*client.SolveStatus) + ValidateLogSource(digest.Digest, interface{}) bool + ClearLogSource(interface{}) + Warnings() []client.VertexWarning + Wait() error +} + type Progress struct { buildID string token string @@ -28,7 +36,7 @@ type Progress struct { lmu sync.Mutex listeners []Listener - p *progress.Printer + p BuildKitProgressWriter } type FinishFn func() @@ -38,7 +46,7 @@ type Listener func(s *client.SolveStatus) // Use the ctx to cancel the long running go routine. // Make sure to run FinishFn to flush remaining build timings to the server _AFTER_ ctx has been canceled. // NOTE: this means that you need to defer the FinishFn before deferring the cancel. -func NewProgress(ctx context.Context, buildID, token string, p *progress.Printer) (*Progress, FinishFn, error) { +func NewProgress(ctx context.Context, buildID, token string, p BuildKitProgressWriter) (*Progress, FinishFn, error) { // Buffer up to 1024 vertex slices before blocking the build. const channelBufferSize = 1024 @@ -61,9 +69,12 @@ func NewProgress(ctx context.Context, buildID, token string, p *progress.Printer // It records duration and success of the log span to sends to Depot for storage. type FinishLogFunc func(err error) +// StartLog starts a log detail span and returns a function that should be called when the log detail is finished. +type StartLogDetailFunc func(message string) FinishLogDetailFunc + // StartLog starts a log span and returns a function that should be called when the log is finished. // Once finished, the log span is recorded and sent to Depot for storage. -func (p *Progress) StartLog(message string) FinishLogFunc { +func (p *Progress) StartLog(message string) (StartLogDetailFunc, FinishLogFunc) { dgst := digest.FromBytes([]byte(identity.NewID())) tm := time.Now() p.Write(&client.SolveStatus{ @@ -74,7 +85,11 @@ func (p *Progress) StartLog(message string) FinishLogFunc { }}, }) - return func(err error) { + logDetail := func(message string) FinishLogDetailFunc { + return p.StartLogDetail(dgst, message) + } + + finishLog := func(err error) { tm2 := time.Now() errMsg := "" if err != nil { @@ -90,11 +105,44 @@ func (p *Progress) StartLog(message string) FinishLogFunc { }}, }) } + + return logDetail, finishLog +} + +// FinishLogFunc is a function that should be called when a log details are finished. +// It records duration the log detail span to sends to Depot for storage. +type FinishLogDetailFunc func() + +func (p *Progress) StartLogDetail(vertexDigest digest.Digest, message string) FinishLogDetailFunc { + started := time.Now() + p.Write(&client.SolveStatus{ + Statuses: []*client.VertexStatus{ + { + ID: message, + Vertex: vertexDigest, + Started: &started, + }, + }, + }) + + return func() { + completed := time.Now() + p.Write(&client.SolveStatus{ + Statuses: []*client.VertexStatus{ + { + ID: message, + Vertex: vertexDigest, + Started: &started, + Completed: &completed, + }, + }, + }) + } } // WithLog wraps a function with timing information. func (p *Progress) WithLog(message string, fn func() error) error { - finishLog := p.StartLog(message) + _, finishLog := p.StartLog(message) err := fn() finishLog(err) return err @@ -102,7 +150,7 @@ func (p *Progress) WithLog(message string, fn func() error) error { // Log writes a log message with no duration. func (p *Progress) Log(message string, err error) { - finishLog := p.StartLog(message) + _, finishLog := p.StartLog(message) finishLog(err) }