diff --git a/cmd/nerdctl/manifest/manifest.go b/cmd/nerdctl/manifest/manifest.go index 69010c299ab..50cecfd97e2 100644 --- a/cmd/nerdctl/manifest/manifest.go +++ b/cmd/nerdctl/manifest/manifest.go @@ -37,6 +37,7 @@ func Command() *cobra.Command { CreateCommand(), AnnotateCommand(), RemoveCommand(), + PushCommand(), ) return cmd diff --git a/cmd/nerdctl/manifest/manifest_push.go b/cmd/nerdctl/manifest/manifest_push.go new file mode 100644 index 00000000000..abb94f98007 --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_push.go @@ -0,0 +1,80 @@ +/* + 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/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" +) + +func PushCommand() *cobra.Command { + var cmd = &cobra.Command{ + Use: "push [OPTIONS] INDEX/MANIFESTLIST", + Short: "Push a manifest list to a registry", + Args: cobra.ExactArgs(1), + RunE: pushAction, + ValidArgsFunction: pushShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.Flags().Bool("insecure", false, "Allow communication with an insecure registry") + cmd.Flags().Bool("purge", false, "Remove the manifest list after pushing") + return cmd +} + +func processPushFlags(cmd *cobra.Command) (types.ManifestPushOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.ManifestPushOptions{}, err + } + + insecure, err := cmd.Flags().GetBool("insecure") + if err != nil { + return types.ManifestPushOptions{}, err + } + purge, err := cmd.Flags().GetBool("purge") + if err != nil { + return types.ManifestPushOptions{}, err + } + + return types.ManifestPushOptions{ + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + Insecure: insecure, + Purge: purge, + }, nil +} + +func pushAction(cmd *cobra.Command, args []string) error { + pushOptions, err := processPushFlags(cmd) + if err != nil { + return err + } + err = manifest.Push(cmd.Context(), args[0], pushOptions) + if err != nil { + return err + } + return nil +} + +func pushShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ImageNames(cmd) +} diff --git a/cmd/nerdctl/manifest/manifest_push_linux_test.go b/cmd/nerdctl/manifest/manifest_push_linux_test.go new file mode 100644 index 00000000000..c92bc1eb436 --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_push_linux_test.go @@ -0,0 +1,124 @@ +/* + 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 ( + "errors" + "fmt" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/registry" +) + +func TestManifestPushErrors(t *testing.T) { + testCase := nerdtest.Setup() + invalidName := "invalid/name/with/special@chars" + testCase.SubTests = []*test.Case{ + { + Description: "require-one-argument", + Command: test.Command("manifest", "push", "arg1", "arg2"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + } + }, + }, + { + Description: "invalid-list-name", + Command: test.Command("manifest", "push", invalidName), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New(data.Labels().Get("error"))}, + } + }, + Data: test.WithLabels(map[string]string{ + "error": "invalid reference format", + }), + }, + } + + testCase.Run(t) +} + +func TestManifestPush(t *testing.T) { + nerdtest.Setup() + + var registryTokenAuthHTTPSRandom *registry.Server + var tokenServer *registry.TokenAuthServer + + manifestRef := testutil.GetTestImageWithoutTag("alpine") + "@" + testutil.GetTestImageManifestDigest("alpine", "linux/amd64") + expectedDigest := "sha256:5317ce2da263afa23570c692d62c1b01381285b2198b3ea9739ce64bec22aff2" + + testCase := &test.Case{ + Require: require.All( + require.Linux, + nerdtest.Registry, + ), + Setup: func(data test.Data, helpers test.Helpers) { + registryTokenAuthHTTPSRandom, tokenServer = nerdtest.RegistryWithTokenAuth(data, helpers, "admin", "badmin", 0, true) + tokenServer.Setup(data, helpers) + registryTokenAuthHTTPSRandom.Setup(data, helpers) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if registryTokenAuthHTTPSRandom != nil { + registryTokenAuthHTTPSRandom.Cleanup(data, helpers) + } + if tokenServer != nil { + tokenServer.Cleanup(data, helpers) + } + }, + SubTests: []*test.Case{ + { + Description: "push-to-registry", + Require: require.Not(nerdtest.Docker), + Setup: func(data test.Data, helpers test.Helpers) { + targetRef := fmt.Sprintf("%s:%d/%s", + registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port, "test-list-push:v1") + helpers.Ensure("pull", manifestRef) + helpers.Ensure("tag", manifestRef, targetRef) + helpers.Ensure("--hosts-dir", registryTokenAuthHTTPSRandom.HostsDir, "login", "-u", "admin", "-p", "badmin", + fmt.Sprintf("%s:%d", registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port)) + helpers.Ensure("push", "--hosts-dir", registryTokenAuthHTTPSRandom.HostsDir, targetRef) + helpers.Ensure("rmi", targetRef) + helpers.Ensure("manifest", "create", "--insecure", targetRef+"-success", targetRef) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + targetRef := fmt.Sprintf("%s:%d/%s", + registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port, "test-list-push:v1") + return helpers.Command("manifest", "push", "--insecure", targetRef+"-success") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.Contains(data.Labels().Get("output")), + } + }, + Data: test.WithLabels(map[string]string{ + "output": expectedDigest, + }), + }, + }, + } + testCase.Run(t) +} diff --git a/docs/command-reference.md b/docs/command-reference.md index fdbf101d27c..fffec04658e 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -55,6 +55,7 @@ It does not necessarily mean that the corresponding features are missing in cont - [:whale: nerdctl manifest annotate](#whale-nerdctl-manifest-annotate) - [:whale: nerdctl manifest create](#whale-nerdctl-manifest-create) - [:whale: nerdctl manifest inspect](#whale-nerdctl-manifest-inspect) + - [:whale: nerdctl manifest push](#whale-nerdctl-manifest-push) - [:whale: nerdctl manifest rm](#whale-nerdctl-manifest-rm) - [Registry](#registry) - [:whale: nerdctl login](#whale-nerdctl-login) @@ -1103,6 +1104,24 @@ nerdctl manifest inspect alpine:3.22.1 nerdctl manifest inspect alpine@sha256:eafc1edb577d2e9b458664a15f23ea1c370214193226069eb22921169fc7e43f ``` +### :whale: nerdctl manifest push + +Push a manifest list to a registry. + +Usage: `nerdctl manifest push [OPTIONS] INDEX/MANIFESTLIST` + +Flags: + +- `--insecure`: Allow communication with an insecure registry +- `--purge`: Remove the manifest list after pushing + +Examples: + +```bash +# Push a manifest list to a registry +nerdctl manifest push myapp:latest +``` + ### :whale: nerdctl manifest rm Remove one or more index/manifest lists. diff --git a/pkg/api/types/manifest_types.go b/pkg/api/types/manifest_types.go index 92a6f8634a5..0bbf45af651 100644 --- a/pkg/api/types/manifest_types.go +++ b/pkg/api/types/manifest_types.go @@ -48,3 +48,13 @@ type ManifestInspectOptions struct { // Allow communication with an insecure registry Insecure bool } + +// ManifestPushOptions specifies options for `nerdctl manifest push`. +type ManifestPushOptions struct { + Stdout io.Writer + GOptions GlobalCommandOptions + // Allow communication with an insecure registry + Insecure bool + // Remove the manifest list after pushing + Purge bool +} diff --git a/pkg/cmd/manifest/push.go b/pkg/cmd/manifest/push.go new file mode 100644 index 00000000000..01923b514dd --- /dev/null +++ b/pkg/cmd/manifest/push.go @@ -0,0 +1,229 @@ +/* + 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" + "strings" + + "github.com/opencontainers/go-digest" + 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/errdefs" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/manifeststore" + "github.com/containerd/nerdctl/v2/pkg/manifesttypes" + "github.com/containerd/nerdctl/v2/pkg/manifestutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" +) + +func Push(ctx context.Context, listRef string, options types.ManifestPushOptions) error { + parsedTargetRef, err := referenceutil.Parse(listRef) + if err != nil { + return fmt.Errorf("failed to parse target reference %s: %w", listRef, err) + } + + manifestStore, err := manifeststore.NewStore(options.GOptions.DataRoot) + if err != nil { + return fmt.Errorf("failed to create manifest store: %w", err) + } + + manifests, err := manifestStore.GetList(parsedTargetRef) + if err != nil { + return fmt.Errorf("failed to get manifests: %w", err) + } + + if len(manifests) == 0 { + return fmt.Errorf("no manifests found for %s", listRef) + } + + resolver, err := manifestutil.CreateResolver(ctx, parsedTargetRef.Domain, options.GOptions, options.Insecure) + if err != nil { + return fmt.Errorf("failed to create resolver: %w", err) + } + + if err := pushIndividualManifests(ctx, resolver, manifests, parsedTargetRef, options); err != nil { + return fmt.Errorf("failed to push individual manifests: %w", err) + } + + manifestList, err := buildManifestList(manifests) + if err != nil { + return fmt.Errorf("failed to build manifest list: %w", err) + } + + digest, err := pushManifestList(ctx, resolver, parsedTargetRef, manifestList) + if err != nil { + return fmt.Errorf("failed to push manifest list: %w", err) + } + + fmt.Fprintln(options.Stdout, digest) + + if options.Purge { + if err := manifestStore.Remove(parsedTargetRef); err != nil { + return fmt.Errorf("failed to remove manifest list from store: %w", err) + } + } + + return nil +} + +func buildManifestList(manifests []*manifesttypes.DockerManifestEntry) (manifesttypes.DockerManifestList, error) { + if len(manifests) == 0 { + return manifesttypes.DockerManifestList{}, fmt.Errorf("no manifests to build list from") + } + + var descriptors []manifesttypes.DockerManifestDescriptor + useOCIIndex := false + + for _, manifest := range manifests { + if manifest.Descriptor.Platform == nil || + manifest.Descriptor.Platform.Architecture == "" || + manifest.Descriptor.Platform.OS == "" { + return manifesttypes.DockerManifestList{}, fmt.Errorf("manifest %s must have an OS and Architecture to be pushed to a registry", manifest.Ref) + } + + if manifest.Descriptor.MediaType == ocispec.MediaTypeImageManifest { + useOCIIndex = true + } + + descriptors = append(descriptors, manifesttypes.DockerManifestDescriptor{ + MediaType: manifest.Descriptor.MediaType, + Size: manifest.Descriptor.Size, + Digest: manifest.Descriptor.Digest, + Platform: *manifest.Descriptor.Platform, + }) + } + manifestList := manifesttypes.DockerManifestList{ + SchemaVersion: 2, + MediaType: images.MediaTypeDockerSchema2ManifestList, + Manifests: descriptors, + } + if useOCIIndex { + manifestList.MediaType = ocispec.MediaTypeImageIndex + } + + return manifestList, nil +} + +func pushIndividualManifests(ctx context.Context, resolver remotes.Resolver, manifests []*manifesttypes.DockerManifestEntry, targetRef *referenceutil.ImageReference, options types.ManifestPushOptions) error { + targetDomain := targetRef.Domain + targetRepo := targetRef.Path + + for _, manifest := range manifests { + manifestRef, err := referenceutil.Parse(manifest.Ref) + if err != nil { + return fmt.Errorf("failed to parse manifest reference %s: %w", manifest.Ref, err) + } + + var targetManifestRef string + if manifestRef.Domain != targetDomain { + targetManifestRef = fmt.Sprintf("%s/%s@%s", targetDomain, manifestRef.Path, manifest.Descriptor.Digest) + } else { + targetManifestRef = fmt.Sprintf("%s/%s@%s", targetDomain, targetRepo, manifest.Descriptor.Digest) + } + + if err := pushManifest(ctx, resolver, targetManifestRef, manifest); err != nil { + return fmt.Errorf("failed to push manifest %s: %w", targetManifestRef, err) + } + + fmt.Fprintf(options.Stdout, "Pushed ref %s with digest: %s\n", targetManifestRef, manifest.Descriptor.Digest) + } + + return nil +} + +func pushManifest(ctx context.Context, resolver remotes.Resolver, ref string, manifest *manifesttypes.DockerManifestEntry) error { + rawData, err := base64.StdEncoding.DecodeString(manifest.Raw) + if err != nil { + return fmt.Errorf("failed to decode manifest data: %w", err) + } + + pusher, err := resolver.Pusher(ctx, ref) + if err != nil { + return fmt.Errorf("failed to create pusher: %w", err) + } + + writer, err := pusher.Push(ctx, manifest.Descriptor) + if err != nil { + if errdefs.IsAlreadyExists(err) || strings.Contains(err.Error(), "already exists") { + return nil + } + return fmt.Errorf("failed to create content writer: %w", err) + } + defer writer.Close() + + if _, err := writer.Write(rawData); err != nil { + return fmt.Errorf("failed to write manifest data: %w", err) + } + + if err := writer.Commit(ctx, manifest.Descriptor.Size, manifest.Descriptor.Digest); err != nil { + if errdefs.IsAlreadyExists(err) || strings.Contains(err.Error(), "already exists") { + return nil + } + return fmt.Errorf("failed to commit manifest: %w", err) + } + + return nil +} + +func pushManifestList(ctx context.Context, resolver remotes.Resolver, targetRef *referenceutil.ImageReference, manifestList manifesttypes.DockerManifestList) (digest.Digest, error) { + data, err := json.MarshalIndent(manifestList, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal manifest list: %w", err) + } + + dgst := digest.FromBytes(data) + + desc := ocispec.Descriptor{ + MediaType: manifestList.MediaType, + Size: int64(len(data)), + Digest: dgst, + } + + pusher, err := resolver.Pusher(ctx, targetRef.String()) + if err != nil { + return "", fmt.Errorf("failed to create pusher: %w", err) + } + + writer, err := pusher.Push(ctx, desc) + if err != nil { + if errdefs.IsAlreadyExists(err) || strings.Contains(err.Error(), "already exists") { + return dgst, nil + } + return "", fmt.Errorf("failed to create content writer: %w", err) + } + defer writer.Close() + + if _, err := writer.Write(data); err != nil { + return "", fmt.Errorf("failed to write manifest list data: %w", err) + } + + if err := writer.Commit(ctx, desc.Size, desc.Digest); err != nil { + if errdefs.IsAlreadyExists(err) || strings.Contains(err.Error(), "already exists") { + return dgst, nil + } + return "", fmt.Errorf("failed to commit manifest list: %w", err) + } + + return dgst, nil +} diff --git a/pkg/manifesttypes/manifesttypes.go b/pkg/manifesttypes/manifesttypes.go index 129c234a13f..7e43b383c54 100644 --- a/pkg/manifesttypes/manifesttypes.go +++ b/pkg/manifesttypes/manifesttypes.go @@ -17,11 +17,12 @@ package manifesttypes import ( + "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) +// For Docker's verbose format type ( - // DockerManifestEntry represents a single manifest entry in Docker's verbose format DockerManifestEntry struct { Ref string `json:"Ref"` @@ -30,6 +31,7 @@ type ( SchemaV2Manifest interface{} `json:"SchemaV2Manifest,omitempty"` OCIManifest interface{} `json:"OCIManifest,omitempty"` } + ManifestStruct struct { SchemaVersion int `json:"schemaVersion"` MediaType string `json:"mediaType"` @@ -38,15 +40,29 @@ type ( 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 + DockerManifestStruct = ManifestStruct + OCIManifestStruct = ManifestStruct + OCIIndexStruct = ocispec.Index +) + +// For manifest push, compatible with Docker distribution spec +type ( + DockerManifestDescriptor struct { + MediaType string `json:"mediaType"` + Size int64 `json:"size"` + Digest digest.Digest `json:"digest"` + Platform ocispec.Platform `json:"platform"` + } - OCIManifestStruct ManifestStruct + DockerManifestList struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType,omitempty"` + Manifests []DockerManifestDescriptor `json:"manifests"` + } )