diff --git a/internal/commands/tag/inspect.go b/internal/commands/tag/inspect.go index e1ed91f..5bce0f0 100644 --- a/internal/commands/tag/inspect.go +++ b/internal/commands/tag/inspect.go @@ -17,11 +17,15 @@ package tag import ( + "context" "encoding/json" "fmt" "io" + "strings" "text/tabwriter" + "time" + "github.com/cli/cli/utils" "github.com/containerd/containerd/images" "github.com/docker/buildx/util/imagetools" "github.com/docker/cli/cli" @@ -45,6 +49,14 @@ type inspectOptions struct { format string } +//Image is the combination of a manifest and its config object +type Image struct { + Name reference.Named + Manifest ocispec.Manifest + Config ocispec.Image + Descriptor ocispec.Descriptor +} + func newInspectCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { var opts inspectOptions cmd := &cobra.Command{ @@ -58,42 +70,38 @@ func newInspectCmd(streams command.Streams, hubClient *hub.Client, parent string return runInspect(streams, hubClient, opts, args[0]) }, } - cmd.Flags().StringVar(&opts.format, "format", "", `Print original manifest ("json")`) + cmd.Flags().StringVar(&opts.format, "format", "", `Print original manifest ("json|raw")`) cmd.Flags().SetInterspersed(false) return cmd } -func runInspect(streams command.Streams, hubClient *hub.Client, opts inspectOptions, image string) error { +func runInspect(streams command.Streams, hubClient *hub.Client, opts inspectOptions, imageRef string) error { resolver := imagetools.New(imagetools.Opt{ Auth: &authResolver{ authConfig: convert(hubClient.AuthConfig), }, }) - raw, descriptor, err := resolver.Get(hubClient.Ctx, image) + raw, descriptor, err := resolver.Get(hubClient.Ctx, imageRef) if err != nil { return err } - if opts.format == "json" { - fmt.Fprintf(streams.Out(), "%s", raw) // avoid newline to keep digest - return nil - } else if opts.format != "" { - return fmt.Errorf("unsupported format type: %q", opts.format) + ref, err := reference.ParseNormalizedNamed(imageRef) + if err != nil { + return err } + ref = reference.TagNameOnly(ref) switch descriptor.MediaType { // case images.MediaTypeDockerSchema2Manifest, specs.MediaTypeImageManifest: // TODO: handle distribution manifest and schema1 case images.MediaTypeDockerSchema2ManifestList, ocispec.MediaTypeImageIndex: - if err := imagetools.PrintManifestList(raw, descriptor, image, streams.Out()); err != nil { - return err - } + return formatManifestlist(streams, opts.format, raw, descriptor, imageRef) case images.MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest: - if err := printManifest(raw, descriptor, image, streams.Out()); err != nil { - return err - } + return formatManifest(hubClient.Ctx, streams, resolver, opts.format, raw, descriptor, ref) default: + fmt.Println("Other mediatype") fmt.Fprintf(streams.Out(), "%s\n", raw) } @@ -104,52 +112,211 @@ const ( pfx = " " ) -func printManifest(raw []byte, descriptor ocispec.Descriptor, image string, out io.Writer) error { - ref, err := reference.ParseNormalizedNamed(image) +func formatManifestlist(streams command.Streams, format string, raw []byte, descriptor ocispec.Descriptor, imageRef string) error { + switch format { + case "json", "raw": + _, err := fmt.Fprintf(streams.Out(), "%s", raw) // avoid newline to keep digest + return err + case "": + return imagetools.PrintManifestList(raw, descriptor, imageRef, streams.Out()) + default: + return fmt.Errorf("unsupported format type: %q", format) + } +} + +func formatManifest(ctx context.Context, streams command.Streams, resolver *imagetools.Resolver, format string, raw []byte, descriptor ocispec.Descriptor, ref reference.Named) error { + image, err := readImage(ctx, resolver, raw, descriptor, ref) if err != nil { return err } - ref = reference.TagNameOnly(ref) + switch format { + case "raw": + _, err := fmt.Fprintf(streams.Out(), "%s", raw) // avoid newline to keep digest + return err + case "json": + buf, err := json.MarshalIndent(image, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprint(streams.Out(), string(buf)) + return err + case "": + return printImage(streams.Out(), image) + default: + return fmt.Errorf("unsupported format type: %q", format) + } +} +func readImage(ctx context.Context, resolver *imagetools.Resolver, rawManifest []byte, descriptor ocispec.Descriptor, ref reference.Named) (*Image, error) { var manifest ocispec.Manifest - if err := json.Unmarshal(raw, &manifest); err != nil { + if err := json.Unmarshal(rawManifest, &manifest); err != nil { + return nil, err + } + + configRef := fmt.Sprintf("%s@%s", ref.Name(), manifest.Config.Digest) + configRaw, err := resolver.GetDescriptor(ctx, configRef, manifest.Config) + if err != nil { + return nil, err + } + var config ocispec.Image + if err := json.Unmarshal(configRaw, &config); err != nil { + return nil, err + } + return &Image{ref, manifest, config, descriptor}, nil +} + +func printImage(out io.Writer, image *Image) error { + if err := printManifest(out, image); err != nil { + return err + } + if err := printConfig(out, image); err != nil { return err } + return printLayers(out, image) +} + +func printManifest(out io.Writer, image *Image) error { w := tabwriter.NewWriter(out, 0, 0, 1, ' ', 0) - fmt.Fprintf(w, "Name:\t%s\n", ref.String()) - fmt.Fprintf(w, "MediaType:\t%s\n", descriptor.MediaType) - fmt.Fprintf(w, "Digest:\t%s\n", descriptor.Digest) + fmt.Fprintf(w, utils.Green("Manifest:")+"\n") + fmt.Fprintf(w, utils.Blue("%sName:")+"\t%s\n", pfx, image.Name) + fmt.Fprintf(w, utils.Blue("%sMediaType:")+"\t%s\n", pfx, image.Descriptor.MediaType) + fmt.Fprintf(w, utils.Blue("%sDigest:")+"\t%s\n", pfx, image.Descriptor.Digest) + if image.Descriptor.Platform != nil { + fmt.Fprintf(w, utils.Blue("%sPlatform:")+"\t%s\n", pfx, getPlatform(image.Descriptor.Platform)) + } + if len(image.Manifest.Annotations) > 0 { + printAnnotations(w, image.Manifest.Annotations) + } else if len(image.Descriptor.Annotations) > 0 { + printAnnotations(w, image.Descriptor.Annotations) + } + if image.Config.Architecture != "" { + fmt.Fprintf(w, utils.Blue("%sOs/Arch:")+"\t%s/%s\n", pfx, image.Config.OS, image.Config.Architecture) + } + if image.Config.Author != "" { + fmt.Fprintf(w, utils.Blue("%sAuthor:")+"\t%s\n", pfx, image.Config.Author) + } + if image.Config.Created != nil { + fmt.Fprintf(w, utils.Blue("%sCreated:")+"\t%s ago\n", pfx, units.HumanDuration(time.Since(*image.Config.Created))) + } fmt.Fprintf(w, "\n") - if err := w.Flush(); err != nil { - return err - } - - w = tabwriter.NewWriter(out, 0, 0, 1, ' ', 0) - fmt.Fprintf(w, "Config:\n") - fmt.Fprintf(w, "%sMediaType:\t%s\n", pfx, manifest.Config.MediaType) - fmt.Fprintf(w, "%sSize:\t%v\n", pfx, units.HumanSize(float64(manifest.Config.Size))) - fmt.Fprintf(w, "%sDigest:\t%s\n", pfx, manifest.Config.Digest) - fmt.Fprintf(w, "\n") - if err := w.Flush(); err != nil { - return err - } - - w = tabwriter.NewWriter(out, 0, 0, 1, ' ', 0) - fmt.Fprintf(w, "Layers:\n") - for i, layer := range manifest.Layers { - if i != 0 { - fmt.Fprintf(w, "\n") - } - fmt.Fprintf(w, "%sMediaType:\t%s\n", pfx, layer.MediaType) - fmt.Fprintf(w, "%sSize:\t%v\n", pfx, units.HumanSize(float64(layer.Size))) - fmt.Fprintf(w, "%sDigest:\t%s\n", pfx, layer.Digest) - } - return w.Flush() } +func printConfig(out io.Writer, image *Image) error { + w := tabwriter.NewWriter(out, 0, 0, 1, ' ', 0) + fmt.Fprintf(w, utils.Green("Config:")+"\n") + fmt.Fprintf(w, utils.Blue("%sMediaType:")+"\t%s\n", pfx, image.Manifest.Config.MediaType) + fmt.Fprintf(w, utils.Blue("%sSize:")+"\t%v\n", pfx, units.HumanSize(float64(image.Manifest.Config.Size))) + fmt.Fprintf(w, utils.Blue("%sDigest:")+"\t%s\n", pfx, image.Manifest.Config.Digest) + if len(image.Config.Config.Cmd) > 0 { + fmt.Fprintf(w, utils.Blue("%sCommand:")+"\t%q\n", pfx, strings.TrimPrefix(strings.Join(image.Config.Config.Cmd, " "), "/bin/sh -c ")) + } + if len(image.Config.Config.Entrypoint) > 0 { + fmt.Fprintf(w, utils.Blue("%sEntrypoint:")+"\t%q\n", pfx, strings.Join(image.Config.Config.Entrypoint, " ")) + } + if image.Config.Config.User != "" { + fmt.Fprintf(w, utils.Blue("%sUser:")+"\t%s\n", pfx, image.Config.Config.User) + } + if len(image.Config.Config.ExposedPorts) > 0 { + fmt.Fprintf(w, utils.Blue("%sExposed ports:")+"\t%s\n", pfx, getExposedPorts(image.Config.Config.ExposedPorts)) + } + if len(image.Config.Config.Env) > 0 { + fmt.Fprintf(w, utils.Blue("%sEnvironment:")+"\n", pfx) + for _, env := range image.Config.Config.Env { + fmt.Fprintf(w, "%s%s%s\n", pfx, pfx, env) + } + } + if len(image.Config.Config.Volumes) > 0 { + fmt.Fprintf(w, utils.Blue("%sVolumes:")+"\n", pfx) + for volume := range image.Config.Config.Volumes { + fmt.Fprintf(w, "%s%s%s\n", pfx, pfx, volume) + } + } + if image.Config.Config.WorkingDir != "" { + fmt.Fprintf(w, utils.Blue("%sWorking Directory:")+"\t%q\n", pfx, image.Config.Config.WorkingDir) + } + if len(image.Config.Config.Labels) > 0 { + fmt.Fprintf(w, utils.Blue("%sLabels:")+"\n", pfx) + for k, v := range image.Config.Config.Labels { + fmt.Fprintf(w, "%s%s%s=%q\n", pfx, pfx, k, v) + } + } + if image.Config.Config.StopSignal != "" { + fmt.Fprintf(w, utils.Blue("%sStop signal:")+"\t%s\n", pfx, image.Config.Config.StopSignal) + } + + fmt.Fprintf(w, "\n") + return w.Flush() +} + +func printLayers(out io.Writer, image *Image) error { + history := filterEmptyLayers(image.Config.History) + w := tabwriter.NewWriter(out, 0, 0, 1, ' ', 0) + fmt.Fprintln(w, utils.Green("Layers:")) + for i, layer := range image.Manifest.Layers { + if i != 0 { + fmt.Fprintln(w) + } + fmt.Fprintf(w, utils.Blue("%sMediaType:")+"\t%s\n", pfx, layer.MediaType) + fmt.Fprintf(w, utils.Blue("%sSize:")+"\t%v\n", pfx, units.HumanSize(float64(layer.Size))) + fmt.Fprintf(w, utils.Blue("%sDigest:")+"\t%s\n", pfx, layer.Digest) + if len(image.Manifest.Layers) == len(history) { + fmt.Fprintf(w, utils.Blue("%sCommand:")+"\t%s\n", pfx, cleanCreatedBy(history[i].CreatedBy)) + if history[i].Created != nil { + fmt.Fprintf(w, utils.Blue("%sCreated:")+"\t%s ago\n", pfx, units.HumanDuration(time.Since(*history[i].Created))) + } + } + } + return w.Flush() +} + +func getPlatform(platform *ocispec.Platform) string { + result := fmt.Sprintf("%s/%s", platform.OS, platform.Architecture) + if platform.Variant != "" { + result += "/" + platform.Variant + } + if platform.OSVersion != "" { + result += "/" + platform.OSVersion + } + if len(platform.OSFeatures) > 0 { + result += "/" + strings.Join(platform.OSFeatures, "/") + } + return result +} + +func cleanCreatedBy(history string) string { + history = strings.TrimPrefix(history, "/bin/sh -c #(nop) ") + history = strings.TrimPrefix(history, "/bin/sh -c ") + return strings.TrimSpace(history) +} + +func filterEmptyLayers(history []ocispec.History) []ocispec.History { + var result []ocispec.History + for _, h := range history { + if !h.EmptyLayer { + result = append(result, h) + } + } + return result +} + +func getExposedPorts(configPorts map[string]struct{}) string { + var ports []string + for port := range configPorts { + ports = append(ports, port) + } + return strings.Join(ports, " ") +} + +func printAnnotations(w io.Writer, annotations map[string]string) { + fmt.Fprintf(w, utils.Blue("%sAnnotations:")+"\n", pfx) + for k, v := range annotations { + fmt.Fprintf(w, "%s%s%s:\t%s\n", pfx, pfx, k, v) + } +} + type authResolver struct { authConfig clitypes.AuthConfig } diff --git a/internal/commands/tag/inspect_test.go b/internal/commands/tag/inspect_test.go index 88a9c02..e70823c 100644 --- a/internal/commands/tag/inspect_test.go +++ b/internal/commands/tag/inspect_test.go @@ -18,16 +18,19 @@ package tag import ( "bytes" - "encoding/json" "testing" + "time" + "github.com/docker/distribution/reference" + "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "gotest.tools/golden" "gotest.tools/v3/assert" ) -func TestPrintManifest(t *testing.T) { +func TestPrintImage(t *testing.T) { manifest := ocispec.Manifest{ + Versioned: specs.Versioned{}, Config: ocispec.Descriptor{ MediaType: "mediatype/config", Digest: "sha256:beef", @@ -40,17 +43,64 @@ func TestPrintManifest(t *testing.T) { Size: 456, }, }, + Annotations: map[string]string{ + "annotation1": "value1", + "annotation2": "value2", + }, + } + now := time.Now() + config := ocispec.Image{ + Created: &now, + Author: "author", + Architecture: "arch", + OS: "os", + Config: ocispec.ImageConfig{ + User: "user", + ExposedPorts: map[string]struct{}{"80/tcp": {}}, + Env: []string{"KEY=VALUE"}, + Entrypoint: []string{"./entrypoint", "parameter"}, + Cmd: []string{"./cmd", "parameter"}, + Volumes: map[string]struct{}{"/volume": {}}, + WorkingDir: "/workingdir", + Labels: map[string]string{"label": "value"}, + StopSignal: "SIGTERM", + }, + History: []ocispec.History{ + // empty layer is ignored + { + Created: &now, + CreatedBy: "created-by-ignored", + Author: "author-ignored", + Comment: "comment-ignored", + EmptyLayer: true, + }, + { + Created: &now, + CreatedBy: "/bin/sh -c #(nop) apt-get install", + Author: "author-history", + Comment: "comment-history", + EmptyLayer: false, + }, + }, } - raw, err := json.Marshal(&manifest) - assert.NilError(t, err) manifestDescriptor := ocispec.Descriptor{ MediaType: "mediatype/manifest", Digest: "sha256:abcdef", Size: 789, + Platform: &ocispec.Platform{ + Architecture: "arch", + OS: "os", + OSVersion: "osversion", + OSFeatures: []string{"feature1", "feature2"}, + Variant: "variant", + }, } - out := bytes.NewBuffer(nil) - - err = printManifest(raw, manifestDescriptor, "image:latest", out) + ref, err := reference.ParseDockerRef("image:latest") assert.NilError(t, err) - golden.Assert(t, out.String(), "printmanifest.golden") + image := Image{ref, manifest, config, manifestDescriptor} + + out := bytes.NewBuffer(nil) + err = printImage(out, &image) + assert.NilError(t, err) + golden.Assert(t, out.String(), "printimage.golden") } diff --git a/internal/commands/tag/test.json b/internal/commands/tag/test.json new file mode 100644 index 0000000..e69de29 diff --git a/internal/commands/tag/testdata/printimage.golden b/internal/commands/tag/testdata/printimage.golden new file mode 100644 index 0000000..a87b179 --- /dev/null +++ b/internal/commands/tag/testdata/printimage.golden @@ -0,0 +1,35 @@ +Manifest: + Name: docker.io/library/image:latest + MediaType: mediatype/manifest + Digest: sha256:abcdef + Platform: os/arch/variant/osversion/feature1/feature2 + Annotations: + annotation1: value1 + annotation2: value2 + Os/Arch: os/arch + Author: author + Created: Less than a second ago + +Config: + MediaType: mediatype/config + Size: 123B + Digest: sha256:beef + Command: "./cmd parameter" + Entrypoint: "./entrypoint parameter" + User: user + Exposed ports: 80/tcp + Environment: + KEY=VALUE + Volumes: + /volume + Working Directory: "/workingdir" + Labels: + label="value" + Stop signal: SIGTERM + +Layers: + MediaType: mediatype/layer + Size: 456B + Digest: sha256:c0ffee + Command: apt-get install + Created: Less than a second ago diff --git a/internal/commands/tag/testdata/printmanifest.golden b/internal/commands/tag/testdata/printmanifest.golden deleted file mode 100644 index 2a36f7e..0000000 --- a/internal/commands/tag/testdata/printmanifest.golden +++ /dev/null @@ -1,13 +0,0 @@ -Name: docker.io/library/image:latest -MediaType: mediatype/manifest -Digest: sha256:abcdef - -Config: - MediaType: mediatype/config - Size: 123B - Digest: sha256:beef - -Layers: - MediaType: mediatype/layer - Size: 456B - Digest: sha256:c0ffee