diff --git a/commands/history/inspect.go b/commands/history/inspect.go new file mode 100644 index 000000000000..8463754f4b40 --- /dev/null +++ b/commands/history/inspect.go @@ -0,0 +1,486 @@ +package history + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/content/proxy" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/platforms" + "github.com/docker/buildx/builder" + "github.com/docker/buildx/localstate" + "github.com/docker/buildx/util/cobrautil/completion" + "github.com/docker/buildx/util/confutil" + "github.com/docker/buildx/util/desktop" + "github.com/docker/cli/cli/command" + slsa "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" + slsa02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + controlapi "github.com/moby/buildkit/api/services/control" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/tonistiigi/go-csvvalue" + "google.golang.org/grpc/codes" +) + +type inspectOptions struct { + builder string + ref string +} + +func runInspect(ctx context.Context, dockerCli command.Cli, opts inspectOptions) error { + b, err := builder.New(dockerCli, builder.WithName(opts.builder)) + if err != nil { + return err + } + + nodes, err := b.LoadNodes(ctx) + if err != nil { + return err + } + for _, node := range nodes { + if node.Err != nil { + return node.Err + } + } + + recs, err := queryRecords(ctx, opts.ref, nodes) + if err != nil { + return err + } + + if len(recs) == 0 { + if opts.ref == "" { + return errors.New("no records found") + } + return errors.Errorf("no record found for ref %q", opts.ref) + } + + if opts.ref == "" { + slices.SortFunc(recs, func(a, b historyRecord) int { + return b.CreatedAt.AsTime().Compare(a.CreatedAt.AsTime()) + }) + } + + rec := &recs[0] + + ls, err := localstate.New(confutil.NewConfig(dockerCli)) + if err != nil { + return err + } + st, _ := ls.ReadRef(rec.node.Builder, rec.node.Name, rec.Ref) + + tw := tabwriter.NewWriter(dockerCli.Out(), 1, 8, 1, '\t', 0) + + attrs := rec.FrontendAttrs + delete(attrs, "frontend.caps") + + writeAttr := func(k, name string, f func(v string) (string, bool)) { + if v, ok := attrs[k]; ok { + if f != nil { + v, ok = f(v) + } + if ok { + fmt.Fprintf(tw, "%s:\t%s\n", name, v) + } + } + delete(attrs, k) + } + + var context string + var dockerfile string + if st != nil { + context = st.LocalPath + dockerfile = st.DockerfilePath + wd, _ := os.Getwd() + + if dockerfile != "" && dockerfile != "-" { + if rel, err := filepath.Rel(context, dockerfile); err == nil { + if !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + dockerfile = rel + } + } + } + if context != "" { + if rel, err := filepath.Rel(wd, context); err == nil { + if !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + context = rel + } + } + } + } + + if v, ok := attrs["context"]; ok && context == "" { + delete(attrs, "context") + context = v + } + if dockerfile == "" { + if v, ok := attrs["filename"]; ok { + dockerfile = v + if dfdir, ok := attrs["vcs:localdir:dockerfile"]; ok { + dockerfile = filepath.Join(dfdir, dockerfile) + } + } + } + delete(attrs, "filename") + + if context != "" { + fmt.Fprintf(tw, "Context:\t%s\n", context) + } + if dockerfile != "" { + fmt.Fprintf(tw, "Dockerfile:\t%s\n", dockerfile) + } + if _, ok := attrs["context"]; !ok { + if src, ok := attrs["vcs:source"]; ok { + fmt.Fprintf(tw, "VCS Repository:\t%s\n", src) + } + if rev, ok := attrs["vcs:revision"]; ok { + fmt.Fprintf(tw, "VCS Revision:\t%s\n", rev) + } + } + + writeAttr("target", "Target", nil) + writeAttr("platform", "Platform", func(v string) (string, bool) { + return tryParseValue(v, func(v string) (string, error) { + var pp []string + for _, v := range strings.Split(v, ",") { + p, err := platforms.Parse(v) + if err != nil { + return "", err + } + pp = append(pp, platforms.FormatAll(platforms.Normalize(p))) + } + return strings.Join(pp, ", "), nil + }), true + }) + writeAttr("build-arg:BUILDKIT_CONTEXT_KEEP_GIT_DIR", "Keep Git Dir", func(v string) (string, bool) { + return tryParseValue(v, func(v string) (string, error) { + b, err := strconv.ParseBool(v) + if err != nil { + return "", err + } + return strconv.FormatBool(b), nil + }), true + }) + + tw.Flush() + + fmt.Fprintln(dockerCli.Out()) + + printTable(dockerCli.Out(), attrs, "context:", "Named Context") + + tw = tabwriter.NewWriter(dockerCli.Out(), 1, 8, 1, '\t', 0) + + fmt.Fprintf(tw, "Started:\t%s\n", rec.CreatedAt.AsTime().Format("2006-01-02 15:04:05")) + var duration time.Duration + var status string + if rec.CompletedAt != nil { + duration = rec.CompletedAt.AsTime().Sub(rec.CreatedAt.AsTime()) + } else { + duration = rec.currentTimestamp.Sub(rec.CreatedAt.AsTime()) + status = " (running)" + } + fmt.Fprintf(tw, "Duration:\t%s%s\n", formatDuration(duration), status) + if rec.Error != nil { + if codes.Code(rec.Error.Code) == codes.Canceled { + fmt.Fprintf(tw, "Status:\tCanceled\n") + } else { + fmt.Fprintf(tw, "Error:\t%s %s\n", codes.Code(rec.Error.Code).String(), rec.Error.Message) + } + } + fmt.Fprintf(tw, "Build Steps:\t%d/%d (%.0f%% cached)\n", rec.NumCompletedSteps, rec.NumTotalSteps, float64(rec.NumCachedSteps)/float64(rec.NumTotalSteps)*100) + tw.Flush() + + fmt.Fprintln(dockerCli.Out()) + + tw = tabwriter.NewWriter(dockerCli.Out(), 1, 8, 1, '\t', 0) + + writeAttr("force-network-mode", "Network", nil) + writeAttr("hostname", "Hostname", nil) + writeAttr("add-hosts", "Extra Hosts", func(v string) (string, bool) { + return tryParseValue(v, func(v string) (string, error) { + fields, err := csvvalue.Fields(v, nil) + if err != nil { + return "", err + } + return strings.Join(fields, ", "), nil + }), true + }) + writeAttr("cgroup-parent", "Cgroup Parent", nil) + writeAttr("image-resolve-mode", "Image Resolve Mode", nil) + writeAttr("multi-platform", "Force Multi-Platform", nil) + writeAttr("build-arg:BUILDKIT_MULTI_PLATFORM", "Force Multi-Platform", nil) + writeAttr("no-cache", "Disable Cache", func(v string) (string, bool) { + if v == "" { + return "true", true + } + return v, true + }) + writeAttr("shm-size", "Shm Size", nil) + writeAttr("ulimit", "Resource Limits", nil) + writeAttr("build-arg:BUILDKIT_CACHE_MOUNT_NS", "Cache Mount Namespace", nil) + writeAttr("build-arg:BUILDKIT_DOCKERFILE_CHECK", "Dockerfile Check Config", nil) + writeAttr("build-arg:SOURCE_DATE_EPOCH", "Source Date Epoch", nil) + writeAttr("build-arg:SANDBOX_HOSTNAME", "Sandbox Hostname", nil) + + var unusedAttrs []string + for k := range attrs { + if strings.HasPrefix(k, "vcs:") || strings.HasPrefix(k, "build-arg:") || strings.HasPrefix(k, "label:") || strings.HasPrefix(k, "context:") || strings.HasPrefix(k, "attest:") { + continue + } + unusedAttrs = append(unusedAttrs, k) + } + slices.Sort(unusedAttrs) + + for _, k := range unusedAttrs { + fmt.Fprintf(tw, "%s:\t%s\n", k, attrs[k]) + } + + tw.Flush() + + fmt.Fprintln(dockerCli.Out()) + + printTable(dockerCli.Out(), attrs, "build-arg:", "Build Arg") + printTable(dockerCli.Out(), attrs, "label:", "Label") + + c, err := rec.node.Driver.Client(ctx) + if err != nil { + return err + } + + store := proxy.NewContentStore(c.ContentClient()) + + attachments, err := allAttachments(ctx, store, *rec) + if err != nil { + return err + } + + provIndex := slices.IndexFunc(attachments, func(a attachment) bool { + return descrType(a.descr) == slsa02.PredicateSLSAProvenance + }) + if provIndex != -1 { + prov := attachments[provIndex] + + dt, err := content.ReadBlob(ctx, store, prov.descr) + if err != nil { + return errors.Errorf("failed to read provenance %s: %v", prov.descr.Digest, err) + } + + var pred provenancetypes.ProvenancePredicate + if err := json.Unmarshal(dt, &pred); err != nil { + return errors.Errorf("failed to unmarshal provenance %s: %v", prov.descr.Digest, err) + } + + fmt.Fprintln(dockerCli.Out(), "Materials:") + tw = tabwriter.NewWriter(dockerCli.Out(), 1, 8, 1, '\t', 0) + fmt.Fprintf(tw, "URI\tDIGEST\n") + for _, m := range pred.Materials { + fmt.Fprintf(tw, "%s\t%s\n", m.URI, strings.Join(digestSetToDigests(m.Digest), ", ")) + } + tw.Flush() + fmt.Fprintln(dockerCli.Out()) + } + + if len(attachments) > 0 { + fmt.Fprintf(tw, "Attachments:\n") + tw = tabwriter.NewWriter(dockerCli.Out(), 1, 8, 1, '\t', 0) + fmt.Fprintf(tw, "DIGEST\tPLATFORM\tTYPE\n") + for _, a := range attachments { + p := "" + if a.platform != nil { + p = platforms.FormatAll(*a.platform) + } + fmt.Fprintf(tw, "%s\t%s\t%s\n", a.descr.Digest, p, descrType(a.descr)) + } + tw.Flush() + fmt.Fprintln(dockerCli.Out()) + } + + fmt.Fprintf(dockerCli.Out(), "Print build logs: docker buildx history logs %s\n", rec.Ref) + + fmt.Fprintf(dockerCli.Out(), "View build in Docker Desktop: %s\n", desktop.BuildURL(rec.Ref)) + + return nil +} + +func inspectCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { + var options inspectOptions + + cmd := &cobra.Command{ + Use: "inspect [OPTIONS] [REF]", + Short: "Inspect a build", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + options.ref = args[0] + } + options.builder = *rootOpts.Builder + return runInspect(cmd.Context(), dockerCli, options) + }, + ValidArgsFunction: completion.Disable, + } + + cmd.AddCommand( + attachmentCmd(dockerCli, rootOpts), + ) + + // flags := cmd.Flags() + + return cmd +} + +type attachment struct { + platform *ocispecs.Platform + descr ocispecs.Descriptor +} + +func allAttachments(ctx context.Context, store content.Store, rec historyRecord) ([]attachment, error) { + var attachments []attachment + + if rec.Result != nil { + for _, a := range rec.Result.Attestations { + attachments = append(attachments, attachment{ + descr: ociDesc(a), + }) + } + for _, r := range rec.Result.Results { + attachments = append(attachments, walkAttachments(ctx, store, ociDesc(r), nil)...) + } + } + + for key, ri := range rec.Results { + p, err := platforms.Parse(key) + if err != nil { + return nil, err + } + for _, a := range ri.Attestations { + attachments = append(attachments, attachment{ + platform: &p, + descr: ociDesc(a), + }) + } + for _, r := range ri.Results { + attachments = append(attachments, walkAttachments(ctx, store, ociDesc(r), &p)...) + } + } + + slices.SortFunc(attachments, func(a, b attachment) int { + pCmp := 0 + if a.platform == nil && b.platform != nil { + return -1 + } else if a.platform != nil && b.platform == nil { + return 1 + } else if a.platform != nil && b.platform != nil { + pCmp = cmp.Compare(platforms.FormatAll(*a.platform), platforms.FormatAll(*b.platform)) + } + return cmp.Or( + pCmp, + cmp.Compare(descrType(a.descr), descrType(b.descr)), + ) + }) + + return attachments, nil +} + +func walkAttachments(ctx context.Context, store content.Store, desc ocispecs.Descriptor, platform *ocispecs.Platform) []attachment { + _, err := store.Info(ctx, desc.Digest) + if err != nil { + return nil + } + + var out []attachment + + if desc.Annotations["vnd.docker.reference.type"] != "attestation-manifest" { + out = append(out, attachment{platform: platform, descr: desc}) + } + + if desc.MediaType != ocispecs.MediaTypeImageIndex && desc.MediaType != images.MediaTypeDockerSchema2ManifestList { + return out + } + + dt, err := content.ReadBlob(ctx, store, desc) + if err != nil { + return out + } + + var idx ocispecs.Index + if err := json.Unmarshal(dt, &idx); err != nil { + return out + } + + for _, d := range idx.Manifests { + p := platform + if d.Platform != nil { + p = d.Platform + } + out = append(out, walkAttachments(ctx, store, d, p)...) + } + + return out +} + +func ociDesc(in *controlapi.Descriptor) ocispecs.Descriptor { + return ocispecs.Descriptor{ + MediaType: in.MediaType, + Digest: digest.Digest(in.Digest), + Size: in.Size, + Annotations: in.Annotations, + } +} +func descrType(desc ocispecs.Descriptor) string { + if typ, ok := desc.Annotations["in-toto.io/predicate-type"]; ok { + return typ + } + return desc.MediaType +} + +func tryParseValue(s string, f func(string) (string, error)) string { + v, err := f(s) + if err != nil { + return fmt.Sprintf("%s (%v)", s, err) + } + return v +} + +func printTable(w io.Writer, attrs map[string]string, prefix, title string) { + var keys []string + for k := range attrs { + if strings.HasPrefix(k, prefix) { + keys = append(keys, strings.TrimPrefix(k, prefix)) + } + } + slices.Sort(keys) + + if len(keys) == 0 { + return + } + + tw := tabwriter.NewWriter(w, 1, 8, 1, '\t', 0) + fmt.Fprintf(tw, "%s\tVALUE\n", strings.ToUpper(title)) + for _, k := range keys { + fmt.Fprintf(tw, "%s\t%s\n", k, attrs[prefix+k]) + } + tw.Flush() + fmt.Fprintln(w) +} + +func digestSetToDigests(ds slsa.DigestSet) []string { + var out []string + for k, v := range ds { + out = append(out, fmt.Sprintf("%s:%s", k, v)) + } + return out +} diff --git a/commands/history/inspect_attachment.go b/commands/history/inspect_attachment.go new file mode 100644 index 000000000000..038e46b555b6 --- /dev/null +++ b/commands/history/inspect_attachment.go @@ -0,0 +1,152 @@ +package history + +import ( + "context" + "io" + "slices" + + "github.com/containerd/containerd/v2/core/content/proxy" + "github.com/containerd/platforms" + "github.com/docker/buildx/builder" + "github.com/docker/buildx/util/cobrautil/completion" + "github.com/docker/cli/cli/command" + intoto "github.com/in-toto/in-toto-golang/in_toto" + slsa02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +type attachmentOptions struct { + builder string + typ string + platform string + ref string + digest digest.Digest +} + +func runAttachment(ctx context.Context, dockerCli command.Cli, opts attachmentOptions) error { + b, err := builder.New(dockerCli, builder.WithName(opts.builder)) + if err != nil { + return err + } + + nodes, err := b.LoadNodes(ctx) + if err != nil { + return err + } + for _, node := range nodes { + if node.Err != nil { + return node.Err + } + } + + recs, err := queryRecords(ctx, opts.ref, nodes) + if err != nil { + return err + } + + if len(recs) == 0 { + if opts.ref == "" { + return errors.New("no records found") + } + return errors.Errorf("no record found for ref %q", opts.ref) + } + + if opts.ref == "" { + slices.SortFunc(recs, func(a, b historyRecord) int { + return b.CreatedAt.AsTime().Compare(a.CreatedAt.AsTime()) + }) + } + + rec := &recs[0] + + c, err := rec.node.Driver.Client(ctx) + if err != nil { + return err + } + + store := proxy.NewContentStore(c.ContentClient()) + + if opts.digest != "" { + ra, err := store.ReaderAt(ctx, ocispecs.Descriptor{Digest: opts.digest}) + if err != nil { + return err + } + _, err = io.Copy(dockerCli.Out(), io.NewSectionReader(ra, 0, ra.Size())) + return err + } + + attachments, err := allAttachments(ctx, store, *rec) + if err != nil { + return err + } + + typ := opts.typ + switch typ { + case "index": + typ = ocispecs.MediaTypeImageIndex + case "manifest": + typ = ocispecs.MediaTypeImageManifest + case "image": + typ = ocispecs.MediaTypeImageConfig + case "provenance": + typ = slsa02.PredicateSLSAProvenance + case "sbom": + typ = intoto.PredicateSPDX + } + + for _, a := range attachments { + if opts.platform != "" && (a.platform == nil || platforms.FormatAll(*a.platform) != opts.platform) { + continue + } + if typ != "" && descrType(a.descr) != typ { + continue + } + ra, err := store.ReaderAt(ctx, a.descr) + if err != nil { + return err + } + _, err = io.Copy(dockerCli.Out(), io.NewSectionReader(ra, 0, ra.Size())) + return err + } + + return errors.Errorf("no matching attachment found for ref %q", opts.ref) +} + +func attachmentCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { + var options attachmentOptions + + cmd := &cobra.Command{ + Use: "attachment [OPTIONS] REF [DIGEST]", + Short: "Inspect a build attachment", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + options.ref = args[0] + } + if len(args) > 1 { + dgst, err := digest.Parse(args[1]) + if err != nil { + return errors.Wrapf(err, "invalid digest %q", args[1]) + } + options.digest = dgst + } + + if options.digest == "" && options.platform == "" && options.typ == "" { + return errors.New("at least one of --type, --platform or DIGEST must be specified") + } + + options.builder = *rootOpts.Builder + return runAttachment(cmd.Context(), dockerCli, options) + }, + ValidArgsFunction: completion.Disable, + } + + flags := cmd.Flags() + flags.StringVar(&options.typ, "type", "", "Type of attachment") + flags.StringVar(&options.platform, "platform", "", "Platform of attachment") + + return cmd +} diff --git a/commands/history/logs.go b/commands/history/logs.go new file mode 100644 index 000000000000..3a280051a15f --- /dev/null +++ b/commands/history/logs.go @@ -0,0 +1,124 @@ +package history + +import ( + "context" + "io" + "os" + "slices" + + "github.com/docker/buildx/builder" + "github.com/docker/buildx/util/cobrautil/completion" + "github.com/docker/buildx/util/progress" + "github.com/docker/cli/cli/command" + controlapi "github.com/moby/buildkit/api/services/control" + "github.com/moby/buildkit/client" + "github.com/moby/buildkit/util/progress/progressui" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +type logsOptions struct { + builder string + ref string + progress string +} + +func runLogs(ctx context.Context, dockerCli command.Cli, opts logsOptions) error { + b, err := builder.New(dockerCli, builder.WithName(opts.builder)) + if err != nil { + return err + } + + nodes, err := b.LoadNodes(ctx) + if err != nil { + return err + } + for _, node := range nodes { + if node.Err != nil { + return node.Err + } + } + + recs, err := queryRecords(ctx, opts.ref, nodes) + if err != nil { + return err + } + + if len(recs) == 0 { + if opts.ref == "" { + return errors.New("no records found") + } + return errors.Errorf("no record found for ref %q", opts.ref) + } + + if opts.ref == "" { + slices.SortFunc(recs, func(a, b historyRecord) int { + return b.CreatedAt.AsTime().Compare(a.CreatedAt.AsTime()) + }) + } + + rec := &recs[0] + c, err := rec.node.Driver.Client(ctx) + if err != nil { + return err + } + + cl, err := c.ControlClient().Status(ctx, &controlapi.StatusRequest{ + Ref: rec.Ref, + }) + if err != nil { + return err + } + + var mode progressui.DisplayMode = progressui.DisplayMode(opts.progress) + if mode == progressui.AutoMode { + mode = progressui.PlainMode + } + printer, err := progress.NewPrinter(context.TODO(), os.Stderr, mode) + if err != nil { + return err + } + +loop0: + for { + select { + case <-ctx.Done(): + cl.CloseSend() + return context.Cause(ctx) + default: + ev, err := cl.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + break loop0 + } + return err + } + printer.Write(client.NewSolveStatus(ev)) + } + } + + return printer.Wait() +} + +func logsCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { + var options logsOptions + + cmd := &cobra.Command{ + Use: "logs [OPTIONS] [REF]", + Short: "Print the logs of a build", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + options.ref = args[0] + } + options.builder = *rootOpts.Builder + return runLogs(cmd.Context(), dockerCli, options) + }, + ValidArgsFunction: completion.Disable, + } + + flags := cmd.Flags() + flags.StringVar(&options.progress, "progress", "plain", "Set type of progress output (plain, rawjson, tty)") + + return cmd +} diff --git a/commands/history/ls.go b/commands/history/ls.go new file mode 100644 index 000000000000..25f9964ff118 --- /dev/null +++ b/commands/history/ls.go @@ -0,0 +1,234 @@ +package history + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/containerd/console" + "github.com/docker/buildx/builder" + "github.com/docker/buildx/localstate" + "github.com/docker/buildx/util/cobrautil/completion" + "github.com/docker/buildx/util/confutil" + "github.com/docker/buildx/util/desktop" + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/formatter" + "github.com/docker/go-units" + "github.com/spf13/cobra" + "golang.org/x/exp/slices" +) + +const ( + lsHeaderBuildID = "BUILD ID" + lsHeaderName = "NAME" + lsHeaderStatus = "STATUS" + lsHeaderCreated = "CREATED AT" + lsHeaderDuration = "DURATION" + lsHeaderLink = "" + + lsDefaultTableFormat = "table {{.Ref}}\t{{.Name}}\t{{.Status}}\t{{.CreatedAt}}\t{{.Duration}}\t{{.Link}}" + + headerKeyTimestamp = "buildkit-current-timestamp" +) + +type lsOptions struct { + builder string + format string + noTrunc bool +} + +func runLs(ctx context.Context, dockerCli command.Cli, opts lsOptions) error { + b, err := builder.New(dockerCli, builder.WithName(opts.builder)) + if err != nil { + return err + } + + nodes, err := b.LoadNodes(ctx) + if err != nil { + return err + } + for _, node := range nodes { + if node.Err != nil { + return node.Err + } + } + + out, err := queryRecords(ctx, "", nodes) + if err != nil { + return err + } + + ls, err := localstate.New(confutil.NewConfig(dockerCli)) + if err != nil { + return err + } + + for i, rec := range out { + st, _ := ls.ReadRef(rec.node.Builder, rec.node.Name, rec.Ref) + rec.name = buildName(rec.FrontendAttrs, st) + out[i] = rec + } + + return lsPrint(dockerCli, out, opts) +} + +func lsCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { + var options lsOptions + + cmd := &cobra.Command{ + Use: "ls", + Short: "List build records", + Args: cli.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + options.builder = *rootOpts.Builder + return runLs(cmd.Context(), dockerCli, options) + }, + ValidArgsFunction: completion.Disable, + } + + flags := cmd.Flags() + flags.StringVar(&options.format, "format", formatter.TableFormatKey, "Format the output") + flags.BoolVar(&options.noTrunc, "no-trunc", false, "Don't truncate output") + + return cmd +} + +func lsPrint(dockerCli command.Cli, records []historyRecord, in lsOptions) error { + if in.format == formatter.TableFormatKey { + in.format = lsDefaultTableFormat + } + + ctx := formatter.Context{ + Output: dockerCli.Out(), + Format: formatter.Format(in.format), + Trunc: !in.noTrunc, + } + + slices.SortFunc(records, func(a, b historyRecord) int { + if a.CompletedAt == nil && b.CompletedAt != nil { + return -1 + } + if a.CompletedAt != nil && b.CompletedAt == nil { + return 1 + } + return b.CreatedAt.AsTime().Compare(a.CreatedAt.AsTime()) + }) + + var term bool + if _, err := console.ConsoleFromFile(os.Stdout); err == nil { + term = true + } + render := func(format func(subContext formatter.SubContext) error) error { + for _, r := range records { + if err := format(&lsContext{ + format: formatter.Format(in.format), + isTerm: term, + trunc: !in.noTrunc, + record: &r, + }); err != nil { + return err + } + } + return nil + } + + lsCtx := lsContext{ + isTerm: term, + trunc: !in.noTrunc, + } + lsCtx.Header = formatter.SubHeaderContext{ + "Ref": lsHeaderBuildID, + "Name": lsHeaderName, + "Status": lsHeaderStatus, + "CreatedAt": lsHeaderCreated, + "Duration": lsHeaderDuration, + "Link": lsHeaderLink, + } + + return ctx.Write(&lsCtx, render) +} + +type lsContext struct { + formatter.HeaderContext + + isTerm bool + trunc bool + format formatter.Format + record *historyRecord +} + +func (c *lsContext) MarshalJSON() ([]byte, error) { + m := map[string]interface{}{ + "ref": c.FullRef(), + "name": c.Name(), + "status": c.Status(), + "created_at": c.record.CreatedAt.AsTime().Format(time.RFC3339Nano), + "total_steps": c.record.NumTotalSteps, + "completed_steps": c.record.NumCompletedSteps, + "cached_steps": c.record.NumCachedSteps, + } + if c.record.CompletedAt != nil { + m["completed_at"] = c.record.CompletedAt.AsTime().Format(time.RFC3339Nano) + } + return json.Marshal(m) +} + +func (c *lsContext) Ref() string { + return c.record.Ref +} + +func (c *lsContext) FullRef() string { + return fmt.Sprintf("%s/%s/%s", c.record.node.Builder, c.record.node.Name, c.record.Ref) +} + +func (c *lsContext) Name() string { + name := c.record.name + if c.trunc && c.format.IsTable() { + return trimBeginning(name, 36) + } + return name +} + +func (c *lsContext) Status() string { + if c.record.CompletedAt != nil { + if c.record.Error != nil { + return "Error" + } + return "Completed" + } + return "Running" +} + +func (c *lsContext) CreatedAt() string { + return units.HumanDuration(time.Since(c.record.CreatedAt.AsTime())) + " ago" +} + +func (c *lsContext) Duration() string { + lastTime := c.record.currentTimestamp + if c.record.CompletedAt != nil { + tm := c.record.CompletedAt.AsTime() + lastTime = &tm + } + if lastTime == nil { + return "" + } + v := formatDuration(lastTime.Sub(c.record.CreatedAt.AsTime())) + if c.record.CompletedAt == nil { + v += "+" + } + return v +} + +func (c *lsContext) Link() string { + url := desktop.BuildURL(c.FullRef()) + if c.format.IsTable() { + if c.isTerm { + return desktop.ANSIHyperlink(url, "Open") + } + return "" + } + return url +} diff --git a/commands/history/open.go b/commands/history/open.go new file mode 100644 index 000000000000..a02bae4a37d8 --- /dev/null +++ b/commands/history/open.go @@ -0,0 +1,80 @@ +package history + +import ( + "context" + "fmt" + "slices" + + "github.com/docker/buildx/builder" + "github.com/docker/buildx/util/cobrautil/completion" + "github.com/docker/buildx/util/desktop" + "github.com/docker/cli/cli/command" + "github.com/pkg/browser" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +type openOptions struct { + builder string + ref string +} + +func runOpen(ctx context.Context, dockerCli command.Cli, opts openOptions) error { + b, err := builder.New(dockerCli, builder.WithName(opts.builder)) + if err != nil { + return err + } + + nodes, err := b.LoadNodes(ctx) + if err != nil { + return err + } + for _, node := range nodes { + if node.Err != nil { + return node.Err + } + } + + recs, err := queryRecords(ctx, opts.ref, nodes) + if err != nil { + return err + } + + if len(recs) == 0 { + if opts.ref == "" { + return errors.New("no records found") + } + return errors.Errorf("no record found for ref %q", opts.ref) + } + + if opts.ref == "" { + slices.SortFunc(recs, func(a, b historyRecord) int { + return b.CreatedAt.AsTime().Compare(a.CreatedAt.AsTime()) + }) + } + + rec := &recs[0] + + url := desktop.BuildURL(fmt.Sprintf("%s/%s/%s", rec.node.Builder, rec.node.Name, rec.Ref)) + return browser.OpenURL(url) +} + +func openCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { + var options openOptions + + cmd := &cobra.Command{ + Use: "open [OPTIONS] [REF]", + Short: "Open a build in Docker Desktop", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + options.ref = args[0] + } + options.builder = *rootOpts.Builder + return runOpen(cmd.Context(), dockerCli, options) + }, + ValidArgsFunction: completion.Disable, + } + + return cmd +} diff --git a/commands/history/rm.go b/commands/history/rm.go new file mode 100644 index 000000000000..596fd567e064 --- /dev/null +++ b/commands/history/rm.go @@ -0,0 +1,151 @@ +package history + +import ( + "context" + "io" + + "github.com/docker/buildx/builder" + "github.com/docker/buildx/util/cobrautil/completion" + "github.com/docker/cli/cli/command" + "github.com/hashicorp/go-multierror" + controlapi "github.com/moby/buildkit/api/services/control" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" +) + +type rmOptions struct { + builder string + refs []string + all bool +} + +func runRm(ctx context.Context, dockerCli command.Cli, opts rmOptions) error { + b, err := builder.New(dockerCli, builder.WithName(opts.builder)) + if err != nil { + return err + } + + nodes, err := b.LoadNodes(ctx) + if err != nil { + return err + } + for _, node := range nodes { + if node.Err != nil { + return node.Err + } + } + + errs := make([][]error, len(opts.refs)) + for i := range errs { + errs[i] = make([]error, len(nodes)) + } + + eg, ctx := errgroup.WithContext(ctx) + for i, node := range nodes { + node := node + eg.Go(func() error { + if node.Driver == nil { + return nil + } + c, err := node.Driver.Client(ctx) + if err != nil { + return err + } + + refs := opts.refs + + if opts.all { + serv, err := c.ControlClient().ListenBuildHistory(ctx, &controlapi.BuildHistoryRequest{ + EarlyExit: true, + }) + if err != nil { + return err + } + defer serv.CloseSend() + + for { + resp, err := serv.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + if resp.Type == controlapi.BuildHistoryEventType_COMPLETE { + refs = append(refs, resp.Record.Ref) + } + } + } + + for j, ref := range refs { + _, err = c.ControlClient().UpdateBuildHistory(ctx, &controlapi.UpdateBuildHistoryRequest{ + Ref: ref, + Delete: true, + }) + if opts.all { + if err != nil { + return err + } + } else { + errs[j][i] = err + } + } + return nil + }) + } + + if err := eg.Wait(); err != nil { + return err + } + + var out []error +loop0: + for _, nodeErrs := range errs { + var nodeErr error + for _, err1 := range nodeErrs { + if err1 == nil { + continue loop0 + } + if nodeErr == nil { + nodeErr = err1 + } else { + nodeErr = multierror.Append(nodeErr, err1) + } + } + out = append(out, nodeErr) + } + if len(out) == 0 { + return nil + } + if len(out) == 1 { + return out[0] + } + return multierror.Append(out[0], out[1:]...) +} + +func rmCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { + var options rmOptions + + cmd := &cobra.Command{ + Use: "rm [OPTIONS] [REF...]", + Short: "Remove build records", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 && !options.all { + return errors.New("rm requires at least one argument") + } + if len(args) > 0 && options.all { + return errors.New("rm requires either --all or at least one argument") + } + options.refs = args + options.builder = *rootOpts.Builder + return runRm(cmd.Context(), dockerCli, options) + }, + ValidArgsFunction: completion.Disable, + } + + flags := cmd.Flags() + flags.BoolVar(&options.all, "all", false, "Remove all build records") + + return cmd +} diff --git a/commands/history/root.go b/commands/history/root.go new file mode 100644 index 000000000000..dbb7dba9b067 --- /dev/null +++ b/commands/history/root.go @@ -0,0 +1,30 @@ +package history + +import ( + "github.com/docker/buildx/util/cobrautil/completion" + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" +) + +type RootOptions struct { + Builder *string +} + +func RootCmd(rootcmd *cobra.Command, dockerCli command.Cli, opts RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "history", + Short: "Commands to work on build records", + ValidArgsFunction: completion.Disable, + RunE: rootcmd.RunE, + } + + cmd.AddCommand( + lsCmd(dockerCli, opts), + rmCmd(dockerCli, opts), + logsCmd(dockerCli, opts), + inspectCmd(dockerCli, opts), + openCmd(dockerCli, opts), + ) + + return cmd +} diff --git a/commands/history/utils.go b/commands/history/utils.go new file mode 100644 index 000000000000..8ac439a5175a --- /dev/null +++ b/commands/history/utils.go @@ -0,0 +1,180 @@ +package history + +import ( + "context" + "fmt" + "io" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/docker/buildx/build" + "github.com/docker/buildx/builder" + "github.com/docker/buildx/localstate" + controlapi "github.com/moby/buildkit/api/services/control" + "github.com/pkg/errors" + "golang.org/x/sync/errgroup" +) + +func buildName(fattrs map[string]string, ls *localstate.State) string { + var res string + + var target, contextPath, dockerfilePath, vcsSource string + if v, ok := fattrs["target"]; ok { + target = v + } + if v, ok := fattrs["context"]; ok { + contextPath = filepath.ToSlash(v) + } else if v, ok := fattrs["vcs:localdir:context"]; ok && v != "." { + contextPath = filepath.ToSlash(v) + } + if v, ok := fattrs["vcs:source"]; ok { + vcsSource = v + } + if v, ok := fattrs["filename"]; ok && v != "Dockerfile" { + dockerfilePath = filepath.ToSlash(v) + } + if v, ok := fattrs["vcs:localdir:dockerfile"]; ok && v != "." { + dockerfilePath = filepath.ToSlash(filepath.Join(v, dockerfilePath)) + } + + var localPath string + if ls != nil && !build.IsRemoteURL(ls.LocalPath) { + if ls.LocalPath != "" && ls.LocalPath != "-" { + localPath = filepath.ToSlash(ls.LocalPath) + } + if ls.DockerfilePath != "" && ls.DockerfilePath != "-" && ls.DockerfilePath != "Dockerfile" { + dockerfilePath = filepath.ToSlash(ls.DockerfilePath) + } + } + + // remove default dockerfile name + const defaultFilename = "/Dockerfile" + hasDefaultFileName := strings.HasSuffix(dockerfilePath, defaultFilename) || dockerfilePath == "" + dockerfilePath = strings.TrimSuffix(dockerfilePath, defaultFilename) + + // dockerfile is a subpath of context + if strings.HasPrefix(dockerfilePath, localPath) && len(dockerfilePath) > len(localPath) { + res = dockerfilePath[strings.LastIndex(localPath, "/")+1:] + } else { + // Otherwise, use basename + bpath := localPath + if len(dockerfilePath) > 0 { + bpath = dockerfilePath + } + if len(bpath) > 0 { + lidx := strings.LastIndex(bpath, "/") + res = bpath[lidx+1:] + if !hasDefaultFileName { + if lidx != -1 { + res = filepath.ToSlash(filepath.Join(filepath.Base(bpath[:lidx]), res)) + } else { + res = filepath.ToSlash(filepath.Join(filepath.Base(bpath), res)) + } + } + } + } + + if len(contextPath) > 0 { + res = contextPath + } + if len(target) > 0 { + if len(res) > 0 { + res = res + " (" + target + ")" + } else { + res = target + } + } + if res == "" && vcsSource != "" { + return vcsSource + } + return res +} + +func trimBeginning(s string, n int) string { + if len(s) <= n { + return s + } + return ".." + s[len(s)-n+2:] +} + +type historyRecord struct { + *controlapi.BuildHistoryRecord + currentTimestamp *time.Time + node *builder.Node + name string +} + +func queryRecords(ctx context.Context, ref string, nodes []builder.Node) ([]historyRecord, error) { + var mu sync.Mutex + var out []historyRecord + + eg, ctx := errgroup.WithContext(ctx) + for _, node := range nodes { + node := node + eg.Go(func() error { + if node.Driver == nil { + return nil + } + var records []historyRecord + c, err := node.Driver.Client(ctx) + if err != nil { + return err + } + serv, err := c.ControlClient().ListenBuildHistory(ctx, &controlapi.BuildHistoryRequest{ + EarlyExit: true, + Ref: ref, + }) + if err != nil { + return err + } + md, err := serv.Header() + if err != nil { + return err + } + var ts *time.Time + if v, ok := md[headerKeyTimestamp]; ok { + t, err := time.Parse(time.RFC3339Nano, v[0]) + if err != nil { + return err + } + ts = &t + } + defer serv.CloseSend() + for { + he, err := serv.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + if he.Type == controlapi.BuildHistoryEventType_DELETED || he.Record == nil { + continue + } + records = append(records, historyRecord{ + BuildHistoryRecord: he.Record, + currentTimestamp: ts, + node: &node, + }) + } + mu.Lock() + out = append(out, records...) + mu.Unlock() + return nil + }) + } + + if err := eg.Wait(); err != nil { + return nil, err + } + return out, nil +} + +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%.1fs", d.Seconds()) + } + return fmt.Sprintf("%dm %2ds", int(d.Minutes()), int(d.Seconds())%60) +} diff --git a/commands/root.go b/commands/root.go index bc82870547b4..4ea29ebe3437 100644 --- a/commands/root.go +++ b/commands/root.go @@ -5,6 +5,7 @@ import ( "os" debugcmd "github.com/docker/buildx/commands/debug" + historycmd "github.com/docker/buildx/commands/history" imagetoolscmd "github.com/docker/buildx/commands/imagetools" "github.com/docker/buildx/controller/remote" "github.com/docker/buildx/util/cobrautil/completion" @@ -106,6 +107,7 @@ func addCommands(cmd *cobra.Command, opts *rootOptions, dockerCli command.Cli) { pruneCmd(dockerCli, opts), duCmd(dockerCli, opts), imagetoolscmd.RootCmd(cmd, dockerCli, imagetoolscmd.RootOptions{Builder: &opts.builder}), + historycmd.RootCmd(cmd, dockerCli, historycmd.RootOptions{Builder: &opts.builder}), ) if confutil.IsExperimental() { cmd.AddCommand(debugcmd.RootCmd(dockerCli, diff --git a/docs/reference/buildx.md b/docs/reference/buildx.md index 783a136ec4aa..6e060ce5c631 100644 --- a/docs/reference/buildx.md +++ b/docs/reference/buildx.md @@ -17,6 +17,7 @@ Extended build capabilities with BuildKit | [`debug`](buildx_debug.md) | Start debugger (EXPERIMENTAL) | | [`dial-stdio`](buildx_dial-stdio.md) | Proxy current stdio streams to builder instance | | [`du`](buildx_du.md) | Disk usage | +| [`history`](buildx_history.md) | Commands to work on build records | | [`imagetools`](buildx_imagetools.md) | Commands to work on images in registry | | [`inspect`](buildx_inspect.md) | Inspect current builder instance | | [`ls`](buildx_ls.md) | List builder instances | diff --git a/docs/reference/buildx_history.md b/docs/reference/buildx_history.md new file mode 100644 index 000000000000..19f7befe09ad --- /dev/null +++ b/docs/reference/buildx_history.md @@ -0,0 +1,26 @@ +# docker buildx history + + +Commands to work on build records + +### Subcommands + +| Name | Description | +|:---------------------------------------|:-------------------------------| +| [`inspect`](buildx_history_inspect.md) | Inspect a build | +| [`logs`](buildx_history_logs.md) | Print the logs of a build | +| [`ls`](buildx_history_ls.md) | List build records | +| [`open`](buildx_history_open.md) | Open a build in Docker Desktop | +| [`rm`](buildx_history_rm.md) | Remove build records | + + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:-----------------------------------------| +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | + + + + diff --git a/docs/reference/buildx_history_inspect.md b/docs/reference/buildx_history_inspect.md new file mode 100644 index 000000000000..d3d6637aed1c --- /dev/null +++ b/docs/reference/buildx_history_inspect.md @@ -0,0 +1,22 @@ +# docker buildx history inspect + + +Inspect a build + +### Subcommands + +| Name | Description | +|:-----------------------------------------------------|:---------------------------| +| [`attachment`](buildx_history_inspect_attachment.md) | Inspect a build attachment | + + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:-----------------------------------------| +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | + + + + diff --git a/docs/reference/buildx_history_inspect_attachment.md b/docs/reference/buildx_history_inspect_attachment.md new file mode 100644 index 000000000000..453e02e2f993 --- /dev/null +++ b/docs/reference/buildx_history_inspect_attachment.md @@ -0,0 +1,17 @@ +# docker buildx history inspect attachment + + +Inspect a build attachment + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:-----------------------------------------| +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | +| `--platform` | `string` | | Platform of attachment | +| `--type` | `string` | | Type of attachment | + + + + diff --git a/docs/reference/buildx_history_logs.md b/docs/reference/buildx_history_logs.md new file mode 100644 index 000000000000..5418ce3932cc --- /dev/null +++ b/docs/reference/buildx_history_logs.md @@ -0,0 +1,16 @@ +# docker buildx history logs + + +Print the logs of a build + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:--------------------------------------------------| +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | +| `--progress` | `string` | `plain` | Set type of progress output (plain, rawjson, tty) | + + + + diff --git a/docs/reference/buildx_history_ls.md b/docs/reference/buildx_history_ls.md new file mode 100644 index 000000000000..e03b64e4bcf2 --- /dev/null +++ b/docs/reference/buildx_history_ls.md @@ -0,0 +1,17 @@ +# docker buildx history ls + + +List build records + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:-----------------------------------------| +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | +| `--format` | `string` | `table` | Format the output | +| `--no-trunc` | `bool` | | Don't truncate output | + + + + diff --git a/docs/reference/buildx_history_open.md b/docs/reference/buildx_history_open.md new file mode 100644 index 000000000000..50ed35ff10a2 --- /dev/null +++ b/docs/reference/buildx_history_open.md @@ -0,0 +1,15 @@ +# docker buildx history open + + +Open a build in Docker Desktop + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:-----------------------------------------| +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | + + + + diff --git a/docs/reference/buildx_history_rm.md b/docs/reference/buildx_history_rm.md new file mode 100644 index 000000000000..34bbf14db257 --- /dev/null +++ b/docs/reference/buildx_history_rm.md @@ -0,0 +1,16 @@ +# docker buildx history rm + + +Remove build records + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:-----------------------------------------| +| `--all` | `bool` | | Remove all build records | +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | + + + + diff --git a/go.mod b/go.mod index 40682eb37d1f..50f6db215ac2 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/hashicorp/go-cty-funcs v0.0.0-20241120183456-c51673e0b3dd + github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/hcl/v2 v2.23.0 github.com/in-toto/in-toto-golang v0.5.0 github.com/mitchellh/hashstructure/v2 v2.0.2 @@ -35,6 +36,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.0 github.com/pelletier/go-toml v1.9.5 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pkg/errors v0.9.1 github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b @@ -49,6 +51,7 @@ require ( go.opentelemetry.io/otel/metric v1.31.0 go.opentelemetry.io/otel/sdk v1.31.0 go.opentelemetry.io/otel/trace v1.31.0 + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 golang.org/x/mod v0.21.0 golang.org/x/sync v0.10.0 golang.org/x/sys v0.28.0 @@ -114,7 +117,6 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -166,7 +168,6 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.31.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect golang.org/x/crypto v0.31.0 // indirect - golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect golang.org/x/time v0.6.0 // indirect diff --git a/go.sum b/go.sum index aca313d3dcd1..cea9f01f097f 100644 --- a/go.sum +++ b/go.sum @@ -359,6 +359,8 @@ github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsq github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/util/desktop/desktop.go b/util/desktop/desktop.go index 563b8f2fd3e2..8f01ba4b73d2 100644 --- a/util/desktop/desktop.go +++ b/util/desktop/desktop.go @@ -28,13 +28,14 @@ func BuildBackendEnabled() bool { return bbEnabled } +func BuildURL(ref string) string { + return fmt.Sprintf("docker-desktop://dashboard/build/%s", ref) +} + func BuildDetailsOutput(refs map[string]string, term bool) string { if len(refs) == 0 { return "" } - refURL := func(ref string) string { - return fmt.Sprintf("docker-desktop://dashboard/build/%s", ref) - } var out bytes.Buffer out.WriteString("View build details: ") multiTargets := len(refs) > 1 @@ -43,9 +44,10 @@ func BuildDetailsOutput(refs map[string]string, term bool) string { out.WriteString(fmt.Sprintf("\n %s: ", target)) } if term { - out.WriteString(hyperlink(refURL(ref))) + url := BuildURL(ref) + out.WriteString(ANSIHyperlink(url, url)) } else { - out.WriteString(refURL(ref)) + out.WriteString(BuildURL(ref)) } } return out.String() @@ -57,9 +59,9 @@ func PrintBuildDetails(w io.Writer, refs map[string]string, term bool) { } } -func hyperlink(url string) string { +func ANSIHyperlink(url, text string) string { // create an escape sequence using the OSC 8 format: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda - return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, url) + return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, text) } type ErrorWithBuildRef struct { diff --git a/vendor/github.com/pkg/browser/LICENSE b/vendor/github.com/pkg/browser/LICENSE new file mode 100644 index 000000000000..65f78fb62910 --- /dev/null +++ b/vendor/github.com/pkg/browser/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2014, Dave Cheney +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/pkg/browser/README.md b/vendor/github.com/pkg/browser/README.md new file mode 100644 index 000000000000..72b1976e3035 --- /dev/null +++ b/vendor/github.com/pkg/browser/README.md @@ -0,0 +1,55 @@ + +# browser + import "github.com/pkg/browser" + +Package browser provides helpers to open files, readers, and urls in a browser window. + +The choice of which browser is started is entirely client dependant. + + + + + +## Variables +``` go +var Stderr io.Writer = os.Stderr +``` +Stderr is the io.Writer to which executed commands write standard error. + +``` go +var Stdout io.Writer = os.Stdout +``` +Stdout is the io.Writer to which executed commands write standard output. + + +## func OpenFile +``` go +func OpenFile(path string) error +``` +OpenFile opens new browser window for the file path. + + +## func OpenReader +``` go +func OpenReader(r io.Reader) error +``` +OpenReader consumes the contents of r and presents the +results in a new browser window. + + +## func OpenURL +``` go +func OpenURL(url string) error +``` +OpenURL opens a new browser window pointing to url. + + + + + + + + + +- - - +Generated by [godoc2md](http://godoc.org/github.com/davecheney/godoc2md) diff --git a/vendor/github.com/pkg/browser/browser.go b/vendor/github.com/pkg/browser/browser.go new file mode 100644 index 000000000000..d7969d74d80d --- /dev/null +++ b/vendor/github.com/pkg/browser/browser.go @@ -0,0 +1,57 @@ +// Package browser provides helpers to open files, readers, and urls in a browser window. +// +// The choice of which browser is started is entirely client dependant. +package browser + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path/filepath" +) + +// Stdout is the io.Writer to which executed commands write standard output. +var Stdout io.Writer = os.Stdout + +// Stderr is the io.Writer to which executed commands write standard error. +var Stderr io.Writer = os.Stderr + +// OpenFile opens new browser window for the file path. +func OpenFile(path string) error { + path, err := filepath.Abs(path) + if err != nil { + return err + } + return OpenURL("file://" + path) +} + +// OpenReader consumes the contents of r and presents the +// results in a new browser window. +func OpenReader(r io.Reader) error { + f, err := ioutil.TempFile("", "browser.*.html") + if err != nil { + return fmt.Errorf("browser: could not create temporary file: %v", err) + } + if _, err := io.Copy(f, r); err != nil { + f.Close() + return fmt.Errorf("browser: caching temporary file failed: %v", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("browser: caching temporary file failed: %v", err) + } + return OpenFile(f.Name()) +} + +// OpenURL opens a new browser window pointing to url. +func OpenURL(url string) error { + return openBrowser(url) +} + +func runCmd(prog string, args ...string) error { + cmd := exec.Command(prog, args...) + cmd.Stdout = Stdout + cmd.Stderr = Stderr + return cmd.Run() +} diff --git a/vendor/github.com/pkg/browser/browser_darwin.go b/vendor/github.com/pkg/browser/browser_darwin.go new file mode 100644 index 000000000000..8507cf7c2b45 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_darwin.go @@ -0,0 +1,5 @@ +package browser + +func openBrowser(url string) error { + return runCmd("open", url) +} diff --git a/vendor/github.com/pkg/browser/browser_freebsd.go b/vendor/github.com/pkg/browser/browser_freebsd.go new file mode 100644 index 000000000000..4fc7ff0761b4 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_freebsd.go @@ -0,0 +1,14 @@ +package browser + +import ( + "errors" + "os/exec" +) + +func openBrowser(url string) error { + err := runCmd("xdg-open", url) + if e, ok := err.(*exec.Error); ok && e.Err == exec.ErrNotFound { + return errors.New("xdg-open: command not found - install xdg-utils from ports(8)") + } + return err +} diff --git a/vendor/github.com/pkg/browser/browser_linux.go b/vendor/github.com/pkg/browser/browser_linux.go new file mode 100644 index 000000000000..d26cdddf9c15 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_linux.go @@ -0,0 +1,21 @@ +package browser + +import ( + "os/exec" + "strings" +) + +func openBrowser(url string) error { + providers := []string{"xdg-open", "x-www-browser", "www-browser"} + + // There are multiple possible providers to open a browser on linux + // One of them is xdg-open, another is x-www-browser, then there's www-browser, etc. + // Look for one that exists and run it + for _, provider := range providers { + if _, err := exec.LookPath(provider); err == nil { + return runCmd(provider, url) + } + } + + return &exec.Error{Name: strings.Join(providers, ","), Err: exec.ErrNotFound} +} diff --git a/vendor/github.com/pkg/browser/browser_netbsd.go b/vendor/github.com/pkg/browser/browser_netbsd.go new file mode 100644 index 000000000000..65a5e5a29342 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_netbsd.go @@ -0,0 +1,14 @@ +package browser + +import ( + "errors" + "os/exec" +) + +func openBrowser(url string) error { + err := runCmd("xdg-open", url) + if e, ok := err.(*exec.Error); ok && e.Err == exec.ErrNotFound { + return errors.New("xdg-open: command not found - install xdg-utils from pkgsrc(7)") + } + return err +} diff --git a/vendor/github.com/pkg/browser/browser_openbsd.go b/vendor/github.com/pkg/browser/browser_openbsd.go new file mode 100644 index 000000000000..4fc7ff0761b4 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_openbsd.go @@ -0,0 +1,14 @@ +package browser + +import ( + "errors" + "os/exec" +) + +func openBrowser(url string) error { + err := runCmd("xdg-open", url) + if e, ok := err.(*exec.Error); ok && e.Err == exec.ErrNotFound { + return errors.New("xdg-open: command not found - install xdg-utils from ports(8)") + } + return err +} diff --git a/vendor/github.com/pkg/browser/browser_unsupported.go b/vendor/github.com/pkg/browser/browser_unsupported.go new file mode 100644 index 000000000000..7c5c17d34d26 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_unsupported.go @@ -0,0 +1,12 @@ +// +build !linux,!windows,!darwin,!openbsd,!freebsd,!netbsd + +package browser + +import ( + "fmt" + "runtime" +) + +func openBrowser(url string) error { + return fmt.Errorf("openBrowser: unsupported operating system: %v", runtime.GOOS) +} diff --git a/vendor/github.com/pkg/browser/browser_windows.go b/vendor/github.com/pkg/browser/browser_windows.go new file mode 100644 index 000000000000..63e192959a5e --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_windows.go @@ -0,0 +1,7 @@ +package browser + +import "golang.org/x/sys/windows" + +func openBrowser(url string) error { + return windows.ShellExecute(0, nil, windows.StringToUTF16Ptr(url), nil, nil, windows.SW_SHOWNORMAL) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index e68d295a5b94..9d9cc8f31863 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -636,6 +636,9 @@ github.com/opencontainers/image-spec/specs-go/v1 # github.com/pelletier/go-toml v1.9.5 ## explicit; go 1.12 github.com/pelletier/go-toml +# github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c +## explicit; go 1.14 +github.com/pkg/browser # github.com/pkg/errors v0.9.1 ## explicit github.com/pkg/errors