diff --git a/cmd/oras/internal/option/confirmation.go b/cmd/oras/internal/option/confirmation.go new file mode 100644 index 000000000..952aff7ce --- /dev/null +++ b/cmd/oras/internal/option/confirmation.go @@ -0,0 +1,56 @@ +/* +Copyright The ORAS 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 option + +import ( + "fmt" + "io" + "strings" + + "github.com/spf13/pflag" +) + +// Confirmation option struct. +type Confirmation struct { + Confirmed bool +} + +// ApplyFlags applies flags to a command flag set. +func (opts *Confirmation) ApplyFlags(fs *pflag.FlagSet) { + fs.BoolVarP(&opts.Confirmed, "yes", "y", false, "do not prompt for confirmation") +} + +// AskForConfirmation prints a propmt to ask for confirmation before doing an +// action and takes user input as response. +func (opts *Confirmation) AskForConfirmation(r io.Reader, prompt string) (bool, error) { + if opts.Confirmed { + return true, nil + } + + fmt.Print(prompt, "[y/N]") + + var response string + if _, err := fmt.Fscanln(r, &response); err != nil { + return false, err + } + + switch strings.ToLower(response) { + case "y", "yes": + return true, nil + default: + return false, nil + } +} diff --git a/cmd/oras/internal/option/confirmation_test.go b/cmd/oras/internal/option/confirmation_test.go new file mode 100644 index 000000000..a4ced3235 --- /dev/null +++ b/cmd/oras/internal/option/confirmation_test.go @@ -0,0 +1,82 @@ +/* +Copyright The ORAS 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 option + +import ( + "reflect" + "strings" + "testing" + + "github.com/spf13/pflag" +) + +func TestConfirmation_ApplyFlags(t *testing.T) { + var test struct{ Confirmation } + ApplyFlags(&test, pflag.NewFlagSet("oras-test", pflag.ExitOnError)) + if test.Confirmation.Confirmed != false { + t.Fatalf("expecting Confirmed to be false but got: %v", test.Confirmation.Confirmed) + } +} + +func TestConfirmation_AskForConfirmation_forciblyConfirmed(t *testing.T) { + opts := Confirmation{ + Confirmed: true, + } + r := strings.NewReader("") + + got, err := opts.AskForConfirmation(r, "") + if err != nil { + t.Fatal("Confirmation.AskForConfirmation() error =", err) + } + if !reflect.DeepEqual(got, true) { + t.Fatalf("Confirmation.AskForConfirmation() got %v, want %v", got, true) + } +} + +func TestConfirmation_AskForConfirmation_manuallyConfirmed(t *testing.T) { + opts := Confirmation{ + Confirmed: false, + } + + r := strings.NewReader("yes") + got, err := opts.AskForConfirmation(r, "") + if err != nil { + t.Fatal("Confirmation.AskForConfirmation() error =", err) + } + if !reflect.DeepEqual(got, true) { + t.Fatalf("Confirmation.AskForConfirmation() got %v, want %v", got, true) + } + + r = strings.NewReader("no") + got, err = opts.AskForConfirmation(r, "") + if err != nil { + t.Fatal("Confirmation.AskForConfirmation() error =", err) + } + if !reflect.DeepEqual(got, false) { + t.Fatalf("Confirmation.AskForConfirmation() got %v, want %v", got, false) + } +} + +func TestConfirmation_AskForConfirmation_FscanlnErr(t *testing.T) { + opts := Confirmation{ + Confirmed: false, + } + r := strings.NewReader("yes no") + + _, err := opts.AskForConfirmation(r, "") + expected := "expected newline" + if err.Error() != expected { + t.Fatalf("AskForConfirmation() error = %v, wantErr %v", err, expected) + } +} diff --git a/cmd/oras/manifest/cmd.go b/cmd/oras/manifest/cmd.go index 24b14488a..ea81eecf6 100644 --- a/cmd/oras/manifest/cmd.go +++ b/cmd/oras/manifest/cmd.go @@ -21,12 +21,13 @@ import ( func Cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "manifest [fetch]", + Use: "manifest [command]", Short: "[Preview] Manifest operations", } cmd.AddCommand( fetchCmd(), + deleteCmd(), ) return cmd } diff --git a/cmd/oras/manifest/delete.go b/cmd/oras/manifest/delete.go new file mode 100644 index 000000000..c99e90c25 --- /dev/null +++ b/cmd/oras/manifest/delete.go @@ -0,0 +1,119 @@ +/* +Copyright The ORAS 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" + "os" + + "github.com/spf13/cobra" + "oras.land/oras-go/v2/errdef" + oerrors "oras.land/oras/cmd/oras/internal/errors" + "oras.land/oras/cmd/oras/internal/option" +) + +type deleteOptions struct { + option.Common + option.Confirmation + option.Descriptor + option.Pretty + option.Remote + + targetRef string +} + +func deleteCmd() *cobra.Command { + var opts deleteOptions + cmd := &cobra.Command{ + Use: "delete [flags] name<:tag|@digest>", + Short: "[Preview] Delete a manifest from remote registry", + Long: `[Preview] Delete a manifest from remote registry + +** This command is in preview and under development. ** + +Example - Delete a manifest tagged with 'latest' from repository 'locahost:5000/hello': + oras manifest delete localhost:5000/hello:latest + +Example - Delete a manifest without prompting confirmation: + oras manifest delete --yes localhost:5000/hello:latest + +Example - Delete a manifest and print its descriptor: + oras manifest delete --descriptor localhost:5000/hello:latest + +Example - Delete a manifest by digest 'sha256:99e4703fbf30916f549cd6bfa9cdbab614b5392fbe64fdee971359a77073cdf9' from repository 'locahost:5000/hello': + oras manifest delete localhost:5000/hello@sha:99e4703fbf30916f549cd6bfa9cdbab614b5392fbe64fdee971359a77073cdf9 +`, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + if opts.OutputDescriptor && !opts.Confirmed { + return errors.New("must apply --yes to confirm the deletion if the descriptor is outputted") + } + return opts.ReadPassword() + }, + RunE: func(_ *cobra.Command, args []string) error { + opts.targetRef = args[0] + return deleteManifest(opts) + }, + } + + option.ApplyFlags(&opts, cmd.Flags()) + return cmd +} + +func deleteManifest(opts deleteOptions) error { + ctx, _ := opts.SetLoggerLevel() + repo, err := opts.NewRepository(opts.targetRef, opts.Common) + if err != nil { + return err + } + + if repo.Reference.Reference == "" { + return oerrors.NewErrInvalidReference(repo.Reference) + } + + manifests := repo.Manifests() + desc, err := manifests.Resolve(ctx, opts.targetRef) + if err != nil { + if errors.Is(err, errdef.ErrNotFound) { + return fmt.Errorf("%s: the specified manifest does not exist", opts.targetRef) + } + return err + } + + prompt := fmt.Sprintf("Are you sure you want to delete the manifest %q and all tags associated with it?", desc.Digest) + confirmed, err := opts.AskForConfirmation(os.Stdin, prompt) + if err != nil { + return err + } + if !confirmed { + return nil + } + + if err = manifests.Delete(ctx, desc); err != nil { + return fmt.Errorf("failed to delete %s: %w", opts.targetRef, err) + } + + if opts.OutputDescriptor { + descJSON, err := opts.Marshal(desc) + if err != nil { + return err + } + return opts.Output(os.Stdout, descJSON) + } + + fmt.Println("Deleted", opts.targetRef) + + return nil +}