diff --git a/cmd/virtwork/main.go b/cmd/virtwork/main.go index 42e0665..03f7f9f 100644 --- a/cmd/virtwork/main.go +++ b/cmd/virtwork/main.go @@ -4,11 +4,14 @@ package main import ( + "bufio" "context" "errors" "fmt" + "io" "log/slog" "os" + "strings" "time" "github.com/spf13/cobra" @@ -104,6 +107,7 @@ func newCleanupCmd() *cobra.Command { cmd.Flags().Bool("delete-namespace", false, "Also delete the namespace") cmd.Flags().String("run-id", "", "Only delete resources from this specific run (UUID)") cmd.Flags().Bool("dry-run", false, "Print intent without destroying resources") + cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt and proceed with cleanup") return cmd } @@ -654,6 +658,43 @@ func cleanupE(cmd *cobra.Command, args []string) error { Message: fmt.Sprintf("Started %s: (namespace: %s, run-id filter: %q)", cmdName, cfg.Namespace, targetRunID), }) + // Preview resources before deletion + preview, err := cleanup.PreviewCleanup(ctx, c, cfg, targetRunID) + if err != nil { + return fmt.Errorf("previewing cleanup: %w", err) + } + + printCleanupPreview(logger, preview, cfg.Namespace, targetRunID) + + if preview.TotalCount == 0 { + logger.Info("nothing to clean up") + _ = auditor.CompleteExecution(ctx, execID, "success", "") + err = nil + return nil + } + + if cfg.DryRun { + logger.Info("dry-run mode — no resources were deleted") + _ = auditor.CompleteExecution(ctx, execID, "success", "") + err = nil + return nil + } + + skipPrompt, _ := cmd.Flags().GetBool("yes") + if !skipPrompt && targetRunID == "" { + _, _ = fmt.Fprint(cmd.OutOrStdout(), "Proceed with deletion? (yes/NO): ") + confirmed, promptErr := PromptForConfirmation(cmd.InOrStdin()) + if promptErr != nil { + return fmt.Errorf("reading confirmation: %w", promptErr) + } + if !confirmed { + logger.Info("cleanup aborted by user") + _ = auditor.CompleteExecution(ctx, execID, "aborted", "user declined confirmation") + err = nil + return nil + } + } + result, err := cleanup.CleanupAll(ctx, c, cfg, deleteNS, targetRunID) if err != nil { return fmt.Errorf("cleanup failed: %w", err) @@ -720,3 +761,36 @@ func printSummary(logger *slog.Logger, vmCount, svcCount, secCount int, cfg *con slog.Int("secrets_created", secCount), slog.String("container_image", cfg.ContainerDiskImage)) } + +func printCleanupPreview(logger *slog.Logger, preview *cleanup.CleanupPreview, namespace, runID string) { + attrs := []slog.Attr{ + slog.String("namespace", namespace), + slog.Int("vms", preview.VMCount), + slog.Int("services", preview.ServiceCount), + slog.Int("secrets", preview.SecretCount), + slog.Int("total", preview.TotalCount), + } + if runID != "" { + attrs = append(attrs, slog.String("run_id_filter", runID)) + } + if len(preview.RunIDs) > 0 { + attrs = append(attrs, slog.String("run_ids", strings.Join(preview.RunIDs, ", "))) + } + args := make([]any, len(attrs)) + for i, a := range attrs { + args[i] = a + } + logger.Info("resources to be deleted", args...) +} + +func PromptForConfirmation(r io.Reader) (bool, error) { + scanner := bufio.NewScanner(r) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return false, fmt.Errorf("reading confirmation: %w", err) + } + return false, nil + } + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + return answer == "yes", nil +} diff --git a/cmd/virtwork/main_test.go b/cmd/virtwork/main_test.go index 85863a4..b22fe91 100644 --- a/cmd/virtwork/main_test.go +++ b/cmd/virtwork/main_test.go @@ -74,6 +74,9 @@ func newRootCmd() *cobra.Command { }, } cleanupCmd.Flags().Bool("delete-namespace", false, "Also delete the namespace") + cleanupCmd.Flags().String("run-id", "", "Only delete resources from this specific run (UUID)") + cleanupCmd.Flags().Bool("dry-run", false, "Print intent without destroying resources") + cleanupCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt and proceed with cleanup") rootCmd.AddCommand(runCmd, cleanupCmd) return rootCmd @@ -275,6 +278,56 @@ var _ = Describe("Cleanup command flags", func() { Expect(err).NotTo(HaveOccurred()) Expect(val).To(BeFalse()) }) + + It("should accept --yes flag", func() { + rootCmd.SetArgs([]string{"cleanup", "--yes"}) + Expect(rootCmd.Execute()).To(Succeed()) + + cleanupCmd, _, _ := rootCmd.Find([]string{"cleanup"}) + val, err := cleanupCmd.Flags().GetBool("yes") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeTrue()) + }) + + It("should accept -y shorthand", func() { + rootCmd.SetArgs([]string{"cleanup", "-y"}) + Expect(rootCmd.Execute()).To(Succeed()) + + cleanupCmd, _, _ := rootCmd.Find([]string{"cleanup"}) + val, err := cleanupCmd.Flags().GetBool("yes") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeTrue()) + }) + + It("should default --yes to false", func() { + rootCmd.SetArgs([]string{"cleanup"}) + Expect(rootCmd.Execute()).To(Succeed()) + + cleanupCmd, _, _ := rootCmd.Find([]string{"cleanup"}) + val, err := cleanupCmd.Flags().GetBool("yes") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeFalse()) + }) + + It("should accept --run-id flag", func() { + rootCmd.SetArgs([]string{"cleanup", "--run-id", "abc-123"}) + Expect(rootCmd.Execute()).To(Succeed()) + + cleanupCmd, _, _ := rootCmd.Find([]string{"cleanup"}) + val, err := cleanupCmd.Flags().GetString("run-id") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("abc-123")) + }) + + It("should accept --dry-run flag on cleanup", func() { + rootCmd.SetArgs([]string{"cleanup", "--dry-run"}) + Expect(rootCmd.Execute()).To(Succeed()) + + cleanupCmd, _, _ := rootCmd.Find([]string{"cleanup"}) + val, err := cleanupCmd.Flags().GetBool("dry-run") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeTrue()) + }) }) // newFakeClient creates a controller-runtime fake client with the KubeVirt scheme. diff --git a/cmd/virtwork/prompt_test.go b/cmd/virtwork/prompt_test.go new file mode 100644 index 0000000..c1e9d2c --- /dev/null +++ b/cmd/virtwork/prompt_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 Red Hat +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "strings" + "testing" +) + +func TestPromptForConfirmation(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"yes confirms", "yes\n", true}, + {"no rejects", "no\n", false}, + {"empty rejects (default NO)", "\n", false}, + {"arbitrary text rejects", "maybe\n", false}, + {"YES case-insensitive", "YES\n", true}, + {"whitespace trimmed", " yes \n", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := strings.NewReader(tt.input) + confirmed, err := PromptForConfirmation(reader) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if confirmed != tt.expected { + t.Errorf("got %v, want %v", confirmed, tt.expected) + } + }) + } +} diff --git a/internal/cleanup/cleanup.go b/internal/cleanup/cleanup.go index e23d286..f15418b 100644 --- a/internal/cleanup/cleanup.go +++ b/internal/cleanup/cleanup.go @@ -27,6 +27,73 @@ type CleanupResult struct { RunIDs []string // unique run IDs collected from cleaned-up resources } +// CleanupPreview summarises what a cleanup operation would delete, without modifying anything. +type CleanupPreview struct { + VMCount int + ServiceCount int + SecretCount int + RunIDs []string + TotalCount int +} + +// PreviewCleanup lists virtwork-managed resources that would be deleted, without modifying anything. +// If runID is non-empty, only resources with that specific virtwork/run-id label are counted. +func PreviewCleanup( + ctx context.Context, + c client.Client, + cfg *config.Config, + runID string, +) (*CleanupPreview, error) { + preview := &CleanupPreview{} + managedLabels := map[string]string{ + constants.LabelManagedBy: constants.ManagedByValue, + } + if runID != "" { + managedLabels[constants.LabelRunID] = runID + } + + listOpts := []client.ListOption{ + client.InNamespace(cfg.Namespace), + client.MatchingLabels(managedLabels), + } + + runIDSet := make(map[string]struct{}) + + vmList := &kubevirtv1.VirtualMachineList{} + if err := c.List(ctx, vmList, listOpts...); err != nil { + return nil, fmt.Errorf("listing VMs in %s: %w", cfg.Namespace, err) + } + preview.VMCount = len(vmList.Items) + for i := range vmList.Items { + collectRunID(vmList.Items[i].Labels, runIDSet) + } + + svcList := &corev1.ServiceList{} + if err := c.List(ctx, svcList, listOpts...); err != nil { + return nil, fmt.Errorf("listing services in %s: %w", cfg.Namespace, err) + } + preview.ServiceCount = len(svcList.Items) + for i := range svcList.Items { + collectRunID(svcList.Items[i].Labels, runIDSet) + } + + secretList := &corev1.SecretList{} + if err := c.List(ctx, secretList, listOpts...); err != nil { + return nil, fmt.Errorf("listing secrets in %s: %w", cfg.Namespace, err) + } + preview.SecretCount = len(secretList.Items) + for i := range secretList.Items { + collectRunID(secretList.Items[i].Labels, runIDSet) + } + + for id := range runIDSet { + preview.RunIDs = append(preview.RunIDs, id) + } + + preview.TotalCount = preview.VMCount + preview.ServiceCount + preview.SecretCount + return preview, nil +} + // CleanupAll deletes all virtwork-managed resources in the given namespace. // If runID is non-empty, only resources with that specific virtwork/run-id label are deleted. // Individual deletion failures are recorded but do not abort the operation. diff --git a/internal/cleanup/cleanup_test.go b/internal/cleanup/cleanup_test.go index 981a7bf..245465e 100644 --- a/internal/cleanup/cleanup_test.go +++ b/internal/cleanup/cleanup_test.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "maps" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -27,6 +28,189 @@ import ( // ErrDeleteResource: error deleting resources var ErrDeleteResource = errors.New("resource deletion error") +var _ = Describe("PreviewCleanup", func() { + var ( + ctx context.Context + scheme = cluster.NewScheme() + namespace = "test-ns" + ) + + BeforeEach(func() { + ctx = context.Background() + }) + + newManagedVM := func(name string, extraLabels ...map[string]string) *kubevirtv1.VirtualMachine { + l := map[string]string{ + constants.LabelManagedBy: constants.ManagedByValue, + } + for _, el := range extraLabels { + maps.Copy(l, el) + } + return vm.BuildVMSpec(vm.VMSpecOpts{ + Name: name, + Namespace: namespace, + ContainerDiskImage: "test-image", + CloudInitUserdata: "#cloud-config\n", + CPUCores: 1, + Memory: "1Gi", + Labels: l, + }) + } + + newManagedSecret := func(name string, extraLabels ...map[string]string) *corev1.Secret { + l := map[string]string{ + constants.LabelManagedBy: constants.ManagedByValue, + } + for _, el := range extraLabels { + maps.Copy(l, el) + } + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: l, + }, + StringData: map[string]string{ + "userdata": "#cloud-config\n", + }, + } + } + + newManagedService := func(name string, extraLabels ...map[string]string) *corev1.Service { + l := map[string]string{ + constants.LabelManagedBy: constants.ManagedByValue, + } + for _, el := range extraLabels { + maps.Copy(l, el) + } + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: l, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + {Port: 80, Protocol: corev1.ProtocolTCP}, + }, + }, + } + } + + It("should return correct counts for mixed resources", func() { + vm1 := newManagedVM("vm-1") + vm2 := newManagedVM("vm-2") + vm3 := newManagedVM("vm-3") + svc1 := newManagedService("svc-1") + sec1 := newManagedSecret("sec-1") + sec2 := newManagedSecret("sec-2") + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(vm1, vm2, vm3, svc1, sec1, sec2). + Build() + + preview, err := cleanup.PreviewCleanup(ctx, c, &config.Config{Namespace: namespace}, "") + Expect(err).NotTo(HaveOccurred()) + Expect(preview.VMCount).To(Equal(3)) + Expect(preview.ServiceCount).To(Equal(1)) + Expect(preview.SecretCount).To(Equal(2)) + Expect(preview.TotalCount).To(Equal(6)) + }) + + It("should respect run-id filtering", func() { + runLabels := map[string]string{constants.LabelRunID: "run-abc"} + vm1 := newManagedVM("vm-1", runLabels) + vm2 := newManagedVM("vm-2") + svc1 := newManagedService("svc-1", runLabels) + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(vm1, vm2, svc1). + Build() + + preview, err := cleanup.PreviewCleanup(ctx, c, &config.Config{Namespace: namespace}, "run-abc") + Expect(err).NotTo(HaveOccurred()) + Expect(preview.VMCount).To(Equal(1)) + Expect(preview.ServiceCount).To(Equal(1)) + Expect(preview.SecretCount).To(Equal(0)) + Expect(preview.TotalCount).To(Equal(2)) + }) + + It("should return zero counts for empty namespace", func() { + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + preview, err := cleanup.PreviewCleanup(ctx, c, &config.Config{Namespace: namespace}, "") + Expect(err).NotTo(HaveOccurred()) + Expect(preview.VMCount).To(Equal(0)) + Expect(preview.ServiceCount).To(Equal(0)) + Expect(preview.SecretCount).To(Equal(0)) + Expect(preview.TotalCount).To(Equal(0)) + Expect(preview.RunIDs).To(BeEmpty()) + }) + + It("should collect run-IDs from resources", func() { + vm1 := newManagedVM("vm-1", map[string]string{constants.LabelRunID: "run-aaa"}) + vm2 := newManagedVM("vm-2", map[string]string{constants.LabelRunID: "run-bbb"}) + svc1 := newManagedService("svc-1", map[string]string{constants.LabelRunID: "run-aaa"}) + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(vm1, vm2, svc1). + Build() + + preview, err := cleanup.PreviewCleanup(ctx, c, &config.Config{Namespace: namespace}, "") + Expect(err).NotTo(HaveOccurred()) + Expect(preview.RunIDs).To(ConsistOf("run-aaa", "run-bbb")) + }) + + It("should not count unmanaged resources", func() { + managedVM := newManagedVM("managed-vm") + unmanagedVM := vm.BuildVMSpec(vm.VMSpecOpts{ + Name: "unmanaged-vm", + Namespace: namespace, + ContainerDiskImage: "test-image", + CloudInitUserdata: "#cloud-config\n", + CPUCores: 1, + Memory: "1Gi", + Labels: map[string]string{ + constants.LabelManagedBy: "other-tool", + }, + }) + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(managedVM, unmanagedVM). + Build() + + preview, err := cleanup.PreviewCleanup(ctx, c, &config.Config{Namespace: namespace}, "") + Expect(err).NotTo(HaveOccurred()) + Expect(preview.VMCount).To(Equal(1)) + Expect(preview.TotalCount).To(Equal(1)) + }) + + It("should not modify any resources", func() { + vm1 := newManagedVM("vm-1") + svc1 := newManagedService("svc-1") + sec1 := newManagedSecret("sec-1") + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(vm1, svc1, sec1). + Build() + + _, err := cleanup.PreviewCleanup(ctx, c, &config.Config{Namespace: namespace}, "") + Expect(err).NotTo(HaveOccurred()) + + vmList := &kubevirtv1.VirtualMachineList{} + Expect(c.List(ctx, vmList, client.InNamespace(namespace))).To(Succeed()) + Expect(vmList.Items).To(HaveLen(1)) + + svcList := &corev1.ServiceList{} + Expect(c.List(ctx, svcList, client.InNamespace(namespace))).To(Succeed()) + Expect(svcList.Items).To(HaveLen(1)) + + secretList := &corev1.SecretList{} + Expect(c.List(ctx, secretList, client.InNamespace(namespace))).To(Succeed()) + Expect(secretList.Items).To(HaveLen(1)) + }) +}) + var _ = Describe("CleanupAll", func() { var ( ctx context.Context diff --git a/tests/e2e/cleanup_test.go b/tests/e2e/cleanup_test.go index 417702a..3173963 100644 --- a/tests/e2e/cleanup_test.go +++ b/tests/e2e/cleanup_test.go @@ -33,12 +33,12 @@ var _ = Describe("virtwork cleanup", Label("slow"), func() { AfterEach(func() { // Safety net cleanup _, _, _, _ = testutil.RunVirtwork("cleanup", - "--namespace", namespace, "--delete-namespace") + "--namespace", namespace, "--delete-namespace", "--yes") }) It("should delete all managed resources", func() { stdout, _, exitCode, err := testutil.RunVirtwork( - "cleanup", "--namespace", namespace) + "cleanup", "--namespace", namespace, "--yes") Expect(err).NotTo(HaveOccurred()) Expect(exitCode).To(Equal(0)) Expect(stdout).To(ContainSubstring(`"vms_deleted":1`)) @@ -46,7 +46,7 @@ var _ = Describe("virtwork cleanup", Label("slow"), func() { It("should not delete the namespace by default", func() { stdout, _, exitCode, err := testutil.RunVirtwork( - "cleanup", "--namespace", namespace) + "cleanup", "--namespace", namespace, "--yes") Expect(err).NotTo(HaveOccurred()) Expect(exitCode).To(Equal(0)) Expect(stdout).NotTo(ContainSubstring("namespace deleted")) @@ -55,7 +55,7 @@ var _ = Describe("virtwork cleanup", Label("slow"), func() { It("should delete namespace when --delete-namespace is set", func() { stdout, _, exitCode, err := testutil.RunVirtwork( "cleanup", "--namespace", namespace, - "--delete-namespace") + "--delete-namespace", "--yes") Expect(err).NotTo(HaveOccurred()) Expect(exitCode).To(Equal(0)) Expect(stdout).To(ContainSubstring("namespace")) @@ -63,7 +63,7 @@ var _ = Describe("virtwork cleanup", Label("slow"), func() { It("should be idempotent (cleanup twice succeeds)", func() { _, _, exitCode1, err := testutil.RunVirtwork( - "cleanup", "--namespace", namespace) + "cleanup", "--namespace", namespace, "--yes") Expect(err).NotTo(HaveOccurred()) Expect(exitCode1).To(Equal(0)) @@ -76,9 +76,9 @@ var _ = Describe("virtwork cleanup", Label("slow"), func() { }, 120*time.Second, 2*time.Second).Should(Equal(0)) stdout, _, exitCode2, err := testutil.RunVirtwork( - "cleanup", "--namespace", namespace) + "cleanup", "--namespace", namespace, "--yes") Expect(err).NotTo(HaveOccurred()) Expect(exitCode2).To(Equal(0)) - Expect(stdout).To(ContainSubstring(`"vms_deleted":0`)) + Expect(stdout).To(ContainSubstring("nothing to clean up")) }) }) diff --git a/tests/e2e/fullcycle_test.go b/tests/e2e/fullcycle_test.go index f389d28..a233111 100644 --- a/tests/e2e/fullcycle_test.go +++ b/tests/e2e/fullcycle_test.go @@ -27,7 +27,7 @@ var _ = Describe("Full deployment cycle", Label("slow"), func() { AfterEach(func() { _, _, _, _ = testutil.RunVirtwork("cleanup", - "--namespace", namespace, "--delete-namespace") + "--namespace", namespace, "--delete-namespace", "--yes") }) It("should deploy CPU workload, wait for readiness, and clean up", func() { @@ -49,7 +49,7 @@ var _ = Describe("Full deployment cycle", Label("slow"), func() { // Step 3: Cleanup stdout, _, exitCode, err = testutil.RunVirtwork( - "cleanup", "--namespace", namespace) + "cleanup", "--namespace", namespace, "--yes") Expect(err).NotTo(HaveOccurred()) Expect(exitCode).To(Equal(0)) Expect(stdout).To(ContainSubstring(`"vms_deleted":1`)) @@ -87,7 +87,7 @@ var _ = Describe("Full deployment cycle", Label("slow"), func() { // Cleanup stdout, _, exitCode, err := testutil.RunVirtwork( - "cleanup", "--namespace", namespace) + "cleanup", "--namespace", namespace, "--yes") Expect(err).NotTo(HaveOccurred()) Expect(exitCode).To(Equal(0)) Expect(stdout).To(ContainSubstring(`"vms_deleted":2`)) diff --git a/tests/e2e/tps_test.go b/tests/e2e/tps_test.go index 75e89d4..09e6833 100644 --- a/tests/e2e/tps_test.go +++ b/tests/e2e/tps_test.go @@ -27,7 +27,7 @@ var _ = Describe("TPS workload", Label("slow"), func() { AfterEach(func() { _, _, _, _ = testutil.RunVirtwork("cleanup", - "--namespace", namespace, "--delete-namespace") + "--namespace", namespace, "--delete-namespace", "--yes") }) It("should deploy server and client VMs with a service", func() { @@ -62,7 +62,7 @@ var _ = Describe("TPS workload", Label("slow"), func() { Expect(exitCode).To(Equal(0)) stdout, _, exitCode, err := testutil.RunVirtwork( - "cleanup", "--namespace", namespace) + "cleanup", "--namespace", namespace, "--yes") Expect(err).NotTo(HaveOccurred()) Expect(exitCode).To(Equal(0)) Expect(stdout).To(ContainSubstring(`"vms_deleted":2`))