Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions cmd/virtwork/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
package main

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"strings"
"time"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
53 changes: 53 additions & 0 deletions cmd/virtwork/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions cmd/virtwork/prompt_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
67 changes: 67 additions & 0 deletions internal/cleanup/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading