diff --git a/cmd/nerdctl/main.go b/cmd/nerdctl/main.go index 55cc12c9bd6..5ada639d7ff 100644 --- a/cmd/nerdctl/main.go +++ b/cmd/nerdctl/main.go @@ -40,6 +40,7 @@ import ( "github.com/containerd/nerdctl/v2/cmd/nerdctl/internal" "github.com/containerd/nerdctl/v2/cmd/nerdctl/ipfs" "github.com/containerd/nerdctl/v2/cmd/nerdctl/login" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/manifest" "github.com/containerd/nerdctl/v2/cmd/nerdctl/namespace" "github.com/containerd/nerdctl/v2/cmd/nerdctl/network" "github.com/containerd/nerdctl/v2/cmd/nerdctl/system" @@ -344,6 +345,9 @@ Config file ($NERDCTL_TOML): %s // IPFS ipfs.NewIPFSCommand(), + + // Manifest + manifest.Command(), ) addApparmorCommand(rootCmd) container.AddCpCommand(rootCmd) diff --git a/cmd/nerdctl/manifest/manifest.go b/cmd/nerdctl/manifest/manifest.go new file mode 100644 index 00000000000..39c63dfa57c --- /dev/null +++ b/cmd/nerdctl/manifest/manifest.go @@ -0,0 +1,40 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" +) + +func Command() *cobra.Command { + cmd := &cobra.Command{ + Annotations: map[string]string{helpers.Category: helpers.Management}, + Use: "manifest", + Short: "Manage image manifests.", + RunE: helpers.UnknownSubcommandAction, + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.AddCommand( + InspectCommand(), + ) + + return cmd +} diff --git a/cmd/nerdctl/manifest/manifest_inspect.go b/cmd/nerdctl/manifest/manifest_inspect.go new file mode 100644 index 00000000000..2572883a4c6 --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_inspect.go @@ -0,0 +1,95 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/cmd/manifest" + "github.com/containerd/nerdctl/v2/pkg/formatter" +) + +func InspectCommand() *cobra.Command { + var cmd = &cobra.Command{ + Use: "inspect MANIFEST", + Short: "Display the contents of a manifest or image index/manifest list", + Args: cobra.MinimumNArgs(1), + RunE: inspectAction, + ValidArgsFunction: inspectShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.Flags().Bool("verbose", false, "Verbose output additional info including layers and platform") + cmd.Flags().Bool("insecure", false, "Allow communication with an insecure registry") + return cmd +} + +func processInspectFlags(cmd *cobra.Command) (types.ManifestInspectOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.ManifestInspectOptions{}, err + } + verbose, err := cmd.Flags().GetBool("verbose") + if err != nil { + return types.ManifestInspectOptions{}, err + } + insecure, err := cmd.Flags().GetBool("insecure") + if err != nil { + return types.ManifestInspectOptions{}, err + } + return types.ManifestInspectOptions{ + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + Verbose: verbose, + Insecure: insecure, + }, nil +} + +func inspectAction(cmd *cobra.Command, args []string) error { + inspectOptions, err := processInspectFlags(cmd) + if err != nil { + return err + } + rawRef := args[0] + res, err := manifest.Inspect(cmd.Context(), rawRef, inspectOptions) + if err != nil { + return err + } + + // Output format: single object for single result, array for multiple results + if len(res) == 1 { + jsonStr, err := formatter.ToJSON(res[0], "", " ") + if err != nil { + return err + } + fmt.Fprint(inspectOptions.Stdout, jsonStr) + } else { + if formatErr := formatter.FormatSlice("", inspectOptions.Stdout, res); formatErr != nil { + return formatErr + } + } + return nil +} + +func inspectShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ImageNames(cmd) +} diff --git a/cmd/nerdctl/manifest/manifest_inspect_linux_test.go b/cmd/nerdctl/manifest/manifest_inspect_linux_test.go new file mode 100644 index 00000000000..a9ca1914013 --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_inspect_linux_test.go @@ -0,0 +1,149 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "encoding/json" + "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + + "github.com/containerd/nerdctl/v2/pkg/manifesttypes" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +const ( + testImageName = "alpine" + testPlatform = "linux/amd64" +) + +type testData struct { + imageName string + platform string + imageRef string + manifestDigest string + configDigest string + rawData string +} + +func newTestData(imageName, platform string) *testData { + return &testData{ + imageName: imageName, + platform: platform, + imageRef: testutil.GetTestImage(imageName), + manifestDigest: testutil.GetTestImageManifestDigest(imageName, platform), + configDigest: testutil.GetTestImageConfigDigest(imageName, platform), + rawData: testutil.GetTestImageRaw(imageName, platform), + } +} + +func (td *testData) imageWithDigest() string { + return testutil.GetTestImageWithoutTag(td.imageName) + "@" + td.manifestDigest +} + +func (td *testData) isAmd64Platform(platform *ocispec.Platform) bool { + return platform != nil && + platform.Architecture == "amd64" && + platform.OS == "linux" +} + +func TestManifestInspect(t *testing.T) { + testCase := nerdtest.Setup() + td := newTestData(testImageName, testPlatform) + + testCase.SubTests = []*test.Case{ + { + Description: "tag-non-verbose", + Command: test.Command("manifest", "inspect", td.imageRef), + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { + var manifest manifesttypes.DockerManifestListStruct + assert.NilError(t, json.Unmarshal([]byte(stdout), &manifest)) + + assert.Equal(t, manifest.SchemaVersion, testutil.GetTestImageSchemaVersion(td.imageName)) + assert.Equal(t, manifest.MediaType, testutil.GetTestImageMediaType(td.imageName)) + assert.Assert(t, len(manifest.Manifests) > 0) + + var foundManifest *ocispec.Descriptor + for _, m := range manifest.Manifests { + if td.isAmd64Platform(m.Platform) { + foundManifest = &m + break + } + } + assert.Assert(t, foundManifest != nil, "should find amd64 platform manifest") + assert.Equal(t, foundManifest.Digest.String(), td.manifestDigest) + assert.Equal(t, foundManifest.MediaType, testutil.GetTestImagePlatformMediaType(td.imageName, td.platform)) + }), + }, + { + Description: "tag-verbose", + Command: test.Command("manifest", "inspect", td.imageRef, "--verbose"), + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { + var entries []manifesttypes.DockerManifestEntry + assert.NilError(t, json.Unmarshal([]byte(stdout), &entries)) + assert.Assert(t, len(entries) > 0) + + var foundEntry *manifesttypes.DockerManifestEntry + for _, e := range entries { + if td.isAmd64Platform(e.Descriptor.Platform) { + foundEntry = &e + break + } + } + assert.Assert(t, foundEntry != nil, "should find amd64 platform entry") + + expectedRef := td.imageRef + "@" + td.manifestDigest + assert.Equal(t, foundEntry.Ref, expectedRef) + assert.Equal(t, foundEntry.Descriptor.Digest.String(), td.manifestDigest) + assert.Equal(t, foundEntry.Descriptor.MediaType, testutil.GetTestImagePlatformMediaType(td.imageName, td.platform)) + assert.Equal(t, foundEntry.Raw, td.rawData) + }), + }, + { + Description: "digest-non-verbose", + Command: test.Command("manifest", "inspect", td.imageWithDigest()), + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { + var manifest manifesttypes.DockerManifestStruct + assert.NilError(t, json.Unmarshal([]byte(stdout), &manifest)) + + assert.Equal(t, manifest.SchemaVersion, testutil.GetTestImageSchemaVersion(td.imageName)) + assert.Equal(t, manifest.MediaType, testutil.GetTestImagePlatformMediaType(td.imageName, td.platform)) + assert.Equal(t, manifest.Config.Digest.String(), td.configDigest) + }), + }, + { + Description: "digest-verbose", + Command: test.Command("manifest", "inspect", td.imageWithDigest(), "--verbose"), + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { + var entry manifesttypes.DockerManifestEntry + assert.NilError(t, json.Unmarshal([]byte(stdout), &entry)) + + assert.Equal(t, entry.Ref, td.imageWithDigest()) + assert.Equal(t, entry.Descriptor.Digest.String(), td.manifestDigest) + assert.Equal(t, entry.Descriptor.MediaType, testutil.GetTestImagePlatformMediaType(td.imageName, td.platform)) + assert.Equal(t, entry.Raw, td.rawData) + }), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/manifest/manifest_test.go b/cmd/nerdctl/manifest/manifest_test.go new file mode 100644 index 00000000000..d4ec523683b --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_test.go @@ -0,0 +1,27 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/docs/command-reference.md b/docs/command-reference.md index d2e82e8a0ea..cca699c3f2a 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -51,6 +51,8 @@ It does not necessarily mean that the corresponding features are missing in cont - [:nerd_face: nerdctl image convert](#nerd_face-nerdctl-image-convert) - [:nerd_face: nerdctl image encrypt](#nerd_face-nerdctl-image-encrypt) - [:nerd_face: nerdctl image decrypt](#nerd_face-nerdctl-image-decrypt) +- [Manifest management](#manifest-management) + - [:whale: nerdctl manifest inspect](#whale-nerdctl-manifest-inspect) - [Registry](#registry) - [:whale: nerdctl login](#whale-nerdctl-login) - [:whale: nerdctl logout](#whale-nerdctl-logout) @@ -1035,6 +1037,31 @@ Flags: - `--platform=` : Convert content for a specific platform - `--all-platforms` : Convert content for all platforms (default: false) +## Manifest management + +### :whale: nerdctl manifest inspect + +Display the contents of a manifest list or manifest. + +Usage: `nerdctl manifest inspect [OPTIONS] MANIFEST` + +#### Input formats + +You can specify the manifest to inspect using one of the following formats: +- **Image name with tag**: `alpine:3.22.1` +- **Image name with digest**: `alpine@sha256:eafc1edb577d2e9b458664a15f23ea1c370214193226069eb22921169fc7e43f` + +Flags: + +- `--verbose` : Verbose output, show additional info including layers and platform +- `--insecure`: Allow communication with an insecure registry +Example: + +```bash +nerdctl manifest inspect alpine:3.22.1 +nerdctl manifest inspect alpine@sha256:eafc1edb577d2e9b458664a15f23ea1c370214193226069eb22921169fc7e43f +``` + ## Registry ### :whale: nerdctl login diff --git a/pkg/api/types/manifest_types.go b/pkg/api/types/manifest_types.go new file mode 100644 index 00000000000..eacd84e4fdc --- /dev/null +++ b/pkg/api/types/manifest_types.go @@ -0,0 +1,29 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package types + +import "io" + +// ManifestInspectOptions specifies options for `nerdctl manifest inspect`. +type ManifestInspectOptions struct { + Stdout io.Writer + GOptions GlobalCommandOptions + // Verbose output additional info including layers and platform + Verbose bool + // Allow communication with an insecure registry + Insecure bool +} diff --git a/pkg/cmd/manifest/inspect.go b/pkg/cmd/manifest/inspect.go new file mode 100644 index 00000000000..8f676f805be --- /dev/null +++ b/pkg/cmd/manifest/inspect.go @@ -0,0 +1,322 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/remotes" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/imgutil/dockerconfigresolver" + "github.com/containerd/nerdctl/v2/pkg/manifesttypes" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" +) + +// manifestParser defines a function type for parsing manifest data +type manifestParser func([]byte) (interface{}, error) + +// manifestParsers maps media types to their parsing functions +var manifestParsers = map[string]manifestParser{ + ocispec.MediaTypeImageManifest: parseOCIManifest, + images.MediaTypeDockerSchema2Manifest: parseDockerManifest, + images.MediaTypeDockerSchema2ManifestList: parseDockerManifestList, + ocispec.MediaTypeImageIndex: parseOCIIndex, +} + +// getManifestFieldName returns the appropriate field name based on media type +func getManifestFieldName(mediaType string) string { + switch mediaType { + case images.MediaTypeDockerSchema2Manifest: + return "SchemaV2Manifest" + case ocispec.MediaTypeImageManifest: + return "OCIManifest" + default: + return "ManifestStruct" + } +} + +func Inspect(ctx context.Context, rawRef string, options types.ManifestInspectOptions) ([]interface{}, error) { + manifest, desc, rawData, err := getManifest(ctx, rawRef, options) + if err != nil { + return nil, err + } + + if options.Verbose { + return formatVerboseOutput(ctx, rawRef, manifest, desc, rawData, options.Insecure) + } + + // Return manifest wrapped in array for formatting compatibility + return []interface{}{manifest}, nil +} + +// formatVerboseOutput formats manifest data in Docker-compatible verbose format +func formatVerboseOutput(ctx context.Context, rawRef string, manifest interface{}, desc ocispec.Descriptor, rawData []byte, insecure bool) ([]interface{}, error) { + switch desc.MediaType { + case ocispec.MediaTypeImageIndex: + index, ok := manifest.(manifesttypes.OCIIndexStruct) + if !ok { + return nil, fmt.Errorf("expected ocispec.Index for OCI index") + } + return verboseEntriesForManifests(ctx, rawRef, index.Manifests, insecure) + + case images.MediaTypeDockerSchema2ManifestList: + di, ok := manifest.(manifesttypes.DockerManifestListStruct) + if !ok { + return nil, fmt.Errorf("expected DockerManifestListStruct for Docker manifest list") + } + return verboseEntriesForManifests(ctx, rawRef, di.Manifests, insecure) + + default: + // Single manifest + entry, err := createManifestEntry(rawRef, desc, rawData) + if err != nil { + return nil, err + } + return []interface{}{entry}, nil + } +} + +// createManifestEntry creates a DockerManifestEntry with proper ManifestStruct +func createManifestEntry(rawRef string, desc ocispec.Descriptor, rawData []byte) (manifesttypes.DockerManifestEntry, error) { + parsedRef, err := referenceutil.Parse(rawRef) + if err != nil { + return manifesttypes.DockerManifestEntry{}, fmt.Errorf("failed to parse reference: %w", err) + } + + var ref string + if parsedRef.Digest != "" { + ref = parsedRef.String() + } else { + ref = fmt.Sprintf("%s@%s", parsedRef.String(), desc.Digest.String()) + } + + entry := manifesttypes.DockerManifestEntry{ + Ref: ref, + Descriptor: desc, + Raw: base64.StdEncoding.EncodeToString(rawData), + } + + // Parse manifest data based on media type + parser, exists := manifestParsers[desc.MediaType] + if !exists { + return manifesttypes.DockerManifestEntry{}, fmt.Errorf("unsupported media type: %s", desc.MediaType) + } + + manifest, err := parser(rawData) + if err != nil { + return manifesttypes.DockerManifestEntry{}, fmt.Errorf("failed to parse manifest: %w", err) + } + + // Set the appropriate manifest field based on media type + fieldName := getManifestFieldName(desc.MediaType) + switch fieldName { + case "SchemaV2Manifest": + entry.SchemaV2Manifest = manifest + case "OCIManifest": + entry.OCIManifest = manifest + } + + // Special handling for OCI manifests to match Docker output + if desc.MediaType == ocispec.MediaTypeImageManifest { + entry.Descriptor.Annotations = nil + } + + return entry, nil +} + +// verboseEntriesForManifests fetches and formats verbose entries for a list of descriptors +func verboseEntriesForManifests(ctx context.Context, rawRef string, manifests []ocispec.Descriptor, insecure bool) ([]interface{}, error) { + parsedRef, err := referenceutil.Parse(rawRef) + if err != nil { + return nil, fmt.Errorf("failed to parse reference: %w", err) + } + + resolver, err := createResolver(ctx, parsedRef.Domain, types.GlobalCommandOptions{}, insecure) + if err != nil { + return nil, fmt.Errorf("failed to create resolver: %w", err) + } + + fetcher, err := resolver.Fetcher(ctx, parsedRef.String()) + if err != nil { + return nil, fmt.Errorf("failed to create fetcher: %w", err) + } + + return fetchAndCreateEntries(ctx, fetcher, rawRef, manifests) +} + +// fetchAndCreateEntries fetches multiple manifests and creates DockerManifestEntry objects +func fetchAndCreateEntries(ctx context.Context, fetcher remotes.Fetcher, rawRef string, manifests []ocispec.Descriptor) ([]interface{}, error) { + entries := make([]interface{}, 0, len(manifests)) + + for _, mdesc := range manifests { + entry, err := fetchAndCreateEntry(ctx, fetcher, rawRef, mdesc) + if err != nil { + return nil, err + } + entries = append(entries, entry) + } + + return entries, nil +} + +// fetchAndCreateEntry fetches a single manifest and creates a DockerManifestEntry +func fetchAndCreateEntry(ctx context.Context, fetcher remotes.Fetcher, rawRef string, desc ocispec.Descriptor) (manifesttypes.DockerManifestEntry, error) { + rc, err := fetcher.Fetch(ctx, desc) + if err != nil { + return manifesttypes.DockerManifestEntry{}, err + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + return manifesttypes.DockerManifestEntry{}, err + } + + entry, err := createManifestEntry(rawRef, desc, data) + if err != nil { + return manifesttypes.DockerManifestEntry{}, err + } + + return entry, nil +} + +// createResolver creates a resolver for registry operations +func createResolver(ctx context.Context, domain string, globalOptions types.GlobalCommandOptions, insecure bool) (remotes.Resolver, error) { + dOpts := buildResolverOptions(globalOptions, insecure) + + resolver, err := dockerconfigresolver.New(ctx, domain, dOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create resolver: %w", err) + } + + return resolver, nil +} + +// buildResolverOptions builds resolver options based on global options and security settings +func buildResolverOptions(globalOptions types.GlobalCommandOptions, insecure bool) []dockerconfigresolver.Opt { + var dOpts []dockerconfigresolver.Opt + + if insecure { + dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) + } + dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(globalOptions.HostsDir)) + + return dOpts +} + +// getManifest returns manifest, descriptor, and raw data in one call +func getManifest(ctx context.Context, rawRef string, options types.ManifestInspectOptions) (interface{}, ocispec.Descriptor, []byte, error) { + parsedRef, err := referenceutil.Parse(rawRef) + if err != nil { + return nil, ocispec.Descriptor{}, nil, fmt.Errorf("failed to parse reference: %w", err) + } + + resolver, err := createResolver(ctx, parsedRef.Domain, options.GOptions, options.Insecure) + if err != nil { + return nil, ocispec.Descriptor{}, nil, fmt.Errorf("failed to create resolver: %w", err) + } + + desc, data, err := fetchManifestData(ctx, resolver, parsedRef.String()) + if err != nil { + return nil, ocispec.Descriptor{}, nil, err + } + + manifest, err := parseManifest(desc.MediaType, data) + if err != nil { + return nil, ocispec.Descriptor{}, nil, err + } + + return manifest, desc, data, nil +} + +// fetchManifestData fetches manifest descriptor and data from the registry +func fetchManifestData(ctx context.Context, resolver remotes.Resolver, ref string) (ocispec.Descriptor, []byte, error) { + _, desc, err := resolver.Resolve(ctx, ref) + if err != nil { + return ocispec.Descriptor{}, nil, fmt.Errorf("failed to resolve %s: %w", ref, err) + } + + fetcher, err := resolver.Fetcher(ctx, ref) + if err != nil { + return ocispec.Descriptor{}, nil, fmt.Errorf("failed to create fetcher: %w", err) + } + + rc, err := fetcher.Fetch(ctx, desc) + if err != nil { + return ocispec.Descriptor{}, nil, fmt.Errorf("failed to fetch manifest: %w", err) + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + return ocispec.Descriptor{}, nil, fmt.Errorf("failed to read manifest data: %w", err) + } + + return desc, data, nil +} + +// parseManifest parses manifest data based on media type +func parseManifest(mediaType string, data []byte) (interface{}, error) { + if parser, exists := manifestParsers[mediaType]; exists { + return parser(data) + } + return nil, fmt.Errorf("unsupported media type: %s", mediaType) +} + +// parseOCIManifest parses OCI manifest data +func parseOCIManifest(data []byte) (interface{}, error) { + var ociManifest manifesttypes.OCIManifestStruct + if err := json.Unmarshal(data, &ociManifest); err != nil { + return nil, fmt.Errorf("failed to unmarshal manifest: %w", err) + } + return ociManifest, nil +} + +// parseDockerManifest parses Docker manifest data +func parseDockerManifest(data []byte) (interface{}, error) { + var dockerManifest manifesttypes.DockerManifestStruct + if err := json.Unmarshal(data, &dockerManifest); err != nil { + return nil, fmt.Errorf("failed to unmarshal docker manifest: %w", err) + } + return dockerManifest, nil +} + +// parseDockerManifestList parses Docker manifest list data +func parseDockerManifestList(data []byte) (interface{}, error) { + var manifestList manifesttypes.DockerManifestListStruct + if err := json.Unmarshal(data, &manifestList); err != nil { + return nil, fmt.Errorf("failed to unmarshal docker index: %w", err) + } + return manifestList, nil +} + +// parseOCIIndex parses OCI index data +func parseOCIIndex(data []byte) (interface{}, error) { + var index manifesttypes.OCIIndexStruct + if err := json.Unmarshal(data, &index); err != nil { + return nil, fmt.Errorf("failed to unmarshal index: %w", err) + } + return index, nil +} diff --git a/pkg/manifesttypes/manifesttypes.go b/pkg/manifesttypes/manifesttypes.go new file mode 100644 index 00000000000..129c234a13f --- /dev/null +++ b/pkg/manifesttypes/manifesttypes.go @@ -0,0 +1,52 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifesttypes + +import ( + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +type ( + + // DockerManifestEntry represents a single manifest entry in Docker's verbose format + DockerManifestEntry struct { + Ref string `json:"Ref"` + Descriptor ocispec.Descriptor `json:"Descriptor"` + Raw string `json:"Raw"` + SchemaV2Manifest interface{} `json:"SchemaV2Manifest,omitempty"` + OCIManifest interface{} `json:"OCIManifest,omitempty"` + } + ManifestStruct struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + Config ocispec.Descriptor `json:"config"` + Layers []ocispec.Descriptor `json:"layers"` + Annotations map[string]string `json:"annotations,omitempty"` + } + + DockerManifestStruct ManifestStruct + + DockerManifestListStruct struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + Manifests []ocispec.Descriptor `json:"manifests"` + } + + OCIIndexStruct ocispec.Index + + OCIManifestStruct ManifestStruct +) diff --git a/pkg/testutil/images.yaml b/pkg/testutil/images.yaml index 5ca8bed656f..405ba8fd3f3 100644 --- a/pkg/testutil/images.yaml +++ b/pkg/testutil/images.yaml @@ -5,8 +5,16 @@ alpine: ref: "ghcr.io/stargz-containers/alpine" tag: "3.13-org" + schemaversion: 2 + mediatype: "application/vnd.docker.distribution.manifest.list.v2+json" digest: "sha256:ec14c7992a97fc11425907e908340c6c3d6ff602f5f13d899e6b7027c9b4133a" variants: ["linux/amd64", "linux/arm64"] + manifests: + linux/amd64: + mediatype: "application/vnd.docker.distribution.manifest.v2+json" + manifest: "sha256:e103c1b4bf019dc290bcc7aca538dc2bf7a9d0fc836e186f5fa34945c5168310" + config: "sha256:49f356fa4513676c5e22e3a8404aad6c7262cc7aaed15341458265320786c58c" + raw: "ewogICAic2NoZW1hVmVyc2lvbiI6IDIsCiAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24vdm5kLmRvY2tlci5kaXN0cmlidXRpb24ubWFuaWZlc3QudjIranNvbiIsCiAgICJjb25maWciOiB7CiAgICAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24vdm5kLmRvY2tlci5jb250YWluZXIuaW1hZ2UudjEranNvbiIsCiAgICAgICJzaXplIjogMTQ3MiwKICAgICAgImRpZ2VzdCI6ICJzaGEyNTY6NDlmMzU2ZmE0NTEzNjc2YzVlMjJlM2E4NDA0YWFkNmM3MjYyY2M3YWFlZDE1MzQxNDU4MjY1MzIwNzg2YzU4YyIKICAgfSwKICAgImxheWVycyI6IFsKICAgICAgewogICAgICAgICAibWVkaWFUeXBlIjogImFwcGxpY2F0aW9uL3ZuZC5kb2NrZXIuaW1hZ2Uucm9vdGZzLmRpZmYudGFyLmd6aXAiLAogICAgICAgICAic2l6ZSI6IDI4MTE5NDcsCiAgICAgICAgICJkaWdlc3QiOiAic2hhMjU2OmNhM2NkNDJhN2M5NTI1ZjZjZTNkNjRjMWE3MDk4MjYxM2E4MjM1ZjBjYzA1N2VjOTI0NDA1MjkyMTg1M2VmMTUiCiAgICAgIH0KICAgXQp9" busybox: ref: "ghcr.io/containerd/busybox" diff --git a/pkg/testutil/images_linux.go b/pkg/testutil/images_linux.go index 641141ca066..c566e6274e5 100644 --- a/pkg/testutil/images_linux.go +++ b/pkg/testutil/images_linux.go @@ -29,30 +29,98 @@ var rawImagesList string var testImagesOnce sync.Once +type manifestInfo struct { + Config string `yaml:"config,omitempty"` + Manifest string `yaml:"manifest,omitempty"` + MediaType string `yaml:"mediatype,omitempty"` + Raw string `yaml:"raw,omitempty"` +} + type TestImage struct { - Ref string `yaml:"ref"` - Tag string `yaml:"tag,omitempty"` - Digest string `yaml:"digest,omitempty"` - Variants []string `yaml:"variants,omitempty"` + Ref string `yaml:"ref"` + Tag string `yaml:"tag,omitempty"` + SchemaVersion int `yaml:"schemaversion,omitempty"` + MediaType string `yaml:"mediatype,omitempty"` + Digest string `yaml:"digest,omitempty"` + Variants []string `yaml:"variants,omitempty"` + Manifests map[string]manifestInfo `yaml:"manifests,omitempty"` } var testImages map[string]TestImage -func getImage(key string) string { +// internal helper to lookup TestImage by key, panics if not found +func lookup(key string) TestImage { testImagesOnce.Do(func() { if err := yaml.Unmarshal([]byte(rawImagesList), &testImages); err != nil { fmt.Printf("Error unmarshaling test images YAML file: %v\n", err) panic("testing is broken") } }) - - var im TestImage - var ok bool - - if im, ok = testImages[key]; !ok { + im, ok := testImages[key] + if !ok { fmt.Printf("Image %s was not found in images list\n", key) panic("testing is broken") } + return im +} +func GetTestImage(key string) string { + im := lookup(key) return im.Ref + ":" + im.Tag } + +func GetTestImageWithoutTag(key string) string { + im := lookup(key) + return im.Ref +} + +func GetTestImageConfigDigest(key, platform string) string { + im := lookup(key) + pd, ok := im.Manifests[platform] + if !ok { + panic(fmt.Sprintf("platform %s not found for image %s", platform, key)) + } + return pd.Config +} + +func GetTestImageManifestDigest(key, platform string) string { + im := lookup(key) + pd, ok := im.Manifests[platform] + if !ok { + panic(fmt.Sprintf("platform %s not found for image %s", platform, key)) + } + return pd.Manifest +} + +func GetTestImageDigest(key string) string { + im := lookup(key) + return im.Digest +} + +func GetTestImageMediaType(key string) string { + im := lookup(key) + return im.MediaType +} + +func GetTestImageSchemaVersion(key string) int { + im := lookup(key) + return im.SchemaVersion +} + +func GetTestImagePlatformMediaType(key, platform string) string { + im := lookup(key) + pd, ok := im.Manifests[platform] + if !ok { + panic(fmt.Sprintf("platform %s not found for image %s", platform, key)) + } + return pd.MediaType +} + +func GetTestImageRaw(key, platform string) string { + im := lookup(key) + pd, ok := im.Manifests[platform] + if !ok { + panic(fmt.Sprintf("platform %s not found for image %s", platform, key)) + } + return pd.Raw +} diff --git a/pkg/testutil/testutil_linux.go b/pkg/testutil/testutil_linux.go index de6e68a58e2..d50d6e30489 100644 --- a/pkg/testutil/testutil_linux.go +++ b/pkg/testutil/testutil_linux.go @@ -17,23 +17,23 @@ package testutil var ( - AlpineImage = getImage("alpine") - BusyboxImage = getImage("busybox") - DockerAuthImage = getImage("docker_auth") - FluentdImage = getImage("fluentd") - GolangImage = getImage("golang") - KuboImage = getImage("kubo") - MariaDBImage = getImage("mariadb") - NginxAlpineImage = getImage("nginx") - RegistryImageStable = getImage("registry") - SystemdImage = getImage("stargz") - WordpressImage = getImage("wordpress") + AlpineImage = GetTestImage("alpine") + BusyboxImage = GetTestImage("busybox") + DockerAuthImage = GetTestImage("docker_auth") + FluentdImage = GetTestImage("fluentd") + GolangImage = GetTestImage("golang") + KuboImage = GetTestImage("kubo") + MariaDBImage = GetTestImage("mariadb") + NginxAlpineImage = GetTestImage("nginx") + RegistryImageStable = GetTestImage("registry") + SystemdImage = GetTestImage("stargz") + WordpressImage = GetTestImage("wordpress") CommonImage = AlpineImage - FedoraESGZImage = getImage("fedora_esgz") // eStargz - FfmpegSociImage = getImage("ffmpeg_soci") // SOCI - UbuntuImage = getImage("ubuntu") // Large enough for testing soci index creation + FedoraESGZImage = GetTestImage("fedora_esgz") // eStargz + FfmpegSociImage = GetTestImage("ffmpeg_soci") // SOCI + UbuntuImage = GetTestImage("ubuntu") // Large enough for testing soci index creation ) const (