diff --git a/cmd/virtwork/main.go b/cmd/virtwork/main.go index 39740bf..810a838 100644 --- a/cmd/virtwork/main.go +++ b/cmd/virtwork/main.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "golang.org/x/sync/errgroup" kubevirtv1 "kubevirt.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" sigyaml "sigs.k8s.io/yaml" "github.com/opdev/virtwork/internal/audit" @@ -192,6 +193,17 @@ func runE(cmd *cobra.Command, args []string) error { ctx := context.Background() + // Connect to cluster early (unless dry-run) to capture context for audit + var c client.Client + if !cfg.DryRun { + var contextName string + c, contextName, err = cluster.Connect(cfg.KubeconfigPath) + if err != nil { + return fmt.Errorf("connecting to cluster: %w", err) + } + cfg.ClusterContext = contextName + } + // Start audit execution cmdName := "run" if cfg.DryRun { @@ -389,12 +401,6 @@ func runE(cmd *cobra.Command, args []string) error { return nil } - // Connect to cluster - c, err := cluster.Connect(cfg.KubeconfigPath) - if err != nil { - return fmt.Errorf("connecting to cluster: %w", err) - } - // Ensure namespace exists if err := resources.EnsureNamespace(ctx, c, cfg.Namespace, map[string]string{ constants.LabelManagedBy: constants.ManagedByValue, @@ -586,6 +592,26 @@ func cleanupE(cmd *cobra.Command, args []string) error { ctx := context.Background() + // Get cleanup flags + deleteNS, _ := cmd.Flags().GetBool("delete-namespace") + targetRunID, _ := cmd.Flags().GetString("run-id") + + // Set cleanup mode for audit + if cfg.DryRun { + cfg.CleanupMode = "dry-run" + } else if targetRunID != "" { + cfg.CleanupMode = "run-id" + } else { + cfg.CleanupMode = "all" + } + + // Connect to cluster early to capture context for audit + c, contextName, err := cluster.Connect(cfg.KubeconfigPath) + if err != nil { + return fmt.Errorf("connecting to cluster: %w", err) + } + cfg.ClusterContext = contextName + // Start audit execution cmdName := "cleanup" if cfg.DryRun { @@ -602,19 +628,11 @@ func cleanupE(cmd *cobra.Command, args []string) error { } }() - deleteNS, _ := cmd.Flags().GetBool("delete-namespace") - targetRunID, _ := cmd.Flags().GetString("run-id") - _ = auditor.RecordEvent(ctx, execID, audit.EventRecord{ EventType: "cleanup_started", Message: fmt.Sprintf("Started %s: (namespace: %s, run-id filter: %q)", cmdName, cfg.Namespace, targetRunID), }) - c, err := cluster.Connect(cfg.KubeconfigPath) - if err != nil { - return fmt.Errorf("connecting to cluster: %w", err) - } - result, err := cleanup.CleanupAll(ctx, c, cfg, deleteNS, targetRunID) if err != nil { return fmt.Errorf("cleanup failed: %w", err) diff --git a/docs/audit-schema.md b/docs/audit-schema.md index 4545c9f..35c97b4 100644 --- a/docs/audit-schema.md +++ b/docs/audit-schema.md @@ -48,7 +48,7 @@ erDiagram TEXT memory INTEGER has_data_disk INTEGER requires_service - TEXT status "pending | created | failed" + TEXT status "pending | created" } vm_details { @@ -97,7 +97,7 @@ erDiagram | `command` | TEXT NOT NULL | `run`, `dry-run`, `cleanup`, or `cleanup --dry-run` | | `status` | TEXT NOT NULL | `in_progress` initially, `success` or `failed` on completion | | `kubeconfig_path` | TEXT | Path used to connect (NULL when in-cluster) | -| `cluster_context` | TEXT | Reserved — currently unused | +| `cluster_context` | TEXT | Current kubeconfig context name; `in-cluster` when using service account; NULL for dry-run | | `namespace` | TEXT NOT NULL | Target namespace | | `container_disk_image` | TEXT | Configured boot image | | `default_cpu_cores` | INTEGER | Global `--cpu-cores` default | @@ -108,7 +108,7 @@ erDiagram | `total_workload_count` | INTEGER | Number of workload types deployed | | `dry_run` | INTEGER NOT NULL | 0 or 1 | | `ssh_auth_configured` | INTEGER NOT NULL | 1 if any SSH credential was provided. **Credentials are never stored.** | -| `cleanup_mode` | TEXT | Reserved — currently unused | +| `cleanup_mode` | TEXT | Cleanup command only: `all` (no filter), `run-id` (specific run), `dry-run` (no deletion); NULL for run commands | | `wait_for_ready` | INTEGER NOT NULL | 0 or 1; reflects `--no-wait` inverted | | `ready_timeout_seconds` | INTEGER | Effective readiness timeout | | `vms_deleted` | INTEGER | Cleanup only: count from `CleanupResult` | @@ -136,8 +136,6 @@ Indexes: `started_at`, `namespace`, `status`, `run_id`. | `data_disk_size` | TEXT | NULL when `has_data_disk = 0` | | `requires_service` | INTEGER NOT NULL | 1 for multi-VM workloads (network, tps) | | `status` | TEXT NOT NULL | `pending` → `created` (after VM create succeeds) → `failed` | -| `started_at` | TEXT | Reserved — currently unset | -| `completed_at` | TEXT | Reserved — currently unset | Indexes: `audit_id`, `workload_type`. diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 625b3b7..7271da6 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -108,14 +108,14 @@ func (a *SQLiteAuditor) StartExecution(ctx context.Context, cmd string, cfg *con res, err := a.db.ExecContext(ctx, ` INSERT INTO audit_log ( - run_id, command, status, kubeconfig_path, namespace, + run_id, command, status, kubeconfig_path, cluster_context, namespace, container_disk_image, default_cpu_cores, default_memory, data_disk_size, workloads_csv, dry_run, ssh_auth_configured, cleanup_mode, wait_for_ready, ready_timeout_seconds, started_at - ) VALUES (?, ?, 'in_progress', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - runID, cmd, cfg.KubeconfigPath, cfg.Namespace, + ) VALUES (?, ?, 'in_progress', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + runID, cmd, nullIfEmpty(cfg.KubeconfigPath), nullIfEmpty(cfg.ClusterContext), cfg.Namespace, cfg.ContainerDiskImage, cfg.CPUCores, cfg.Memory, cfg.DataDiskSize, - workloadsCSV, boolToInt(cfg.DryRun), boolToInt(sshConfigured), cfg.CleanupMode, + workloadsCSV, boolToInt(cfg.DryRun), boolToInt(sshConfigured), nullIfEmpty(cfg.CleanupMode), boolToInt(cfg.WaitForReady), cfg.ReadyTimeoutSeconds, now(), ) if err != nil { @@ -173,10 +173,10 @@ func (a *SQLiteAuditor) RecordWorkload(ctx context.Context, executionID int64, w res, err := a.db.ExecContext(ctx, ` INSERT INTO workload_details ( audit_id, workload_type, enabled, vm_count, cpu_cores, memory, - has_data_disk, data_disk_size, requires_service, status, started_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'created', ?)`, + has_data_disk, data_disk_size, requires_service, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')`, executionID, w.WorkloadType, boolToInt(w.Enabled), w.VMCount, w.CPUCores, w.Memory, - boolToInt(w.HasDataDisk), nullIfEmpty(w.DataDiskSize), boolToInt(w.RequiresService), now(), + boolToInt(w.HasDataDisk), nullIfEmpty(w.DataDiskSize), boolToInt(w.RequiresService), ) if err != nil { return 0, fmt.Errorf("inserting workload_details: %w", err) @@ -186,8 +186,8 @@ func (a *SQLiteAuditor) RecordWorkload(ctx context.Context, executionID int64, w func (a *SQLiteAuditor) UpdateWorkloadStatus(ctx context.Context, id int64, status string) error { _, err := a.db.ExecContext(ctx, - `UPDATE workload_details SET status = ?, completed_at = ? WHERE id = ?`, - status, now(), id) + `UPDATE workload_details SET status = ? WHERE id = ?`, + status, id) return err } diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index 44b08b3..b2b61f5 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -412,6 +412,118 @@ var _ = Describe("SQLiteAuditor", func() { Expect(sshAuth).To(Equal(0)) }) }) + + Describe("cluster context tracking", func() { + It("records cluster_context when provided", func() { + cfg := &config.Config{ + Namespace: "test-ns", + ClusterContext: "my-cluster-context", + } + execID, _, err := auditor.StartExecution(ctx, "run", cfg) + Expect(err).NotTo(HaveOccurred()) + + db := auditor.DB() + var clusterContext sql.NullString + err = db.QueryRow(`SELECT cluster_context FROM audit_log WHERE id = ?`, execID).Scan(&clusterContext) + Expect(err).NotTo(HaveOccurred()) + Expect(clusterContext.Valid).To(BeTrue()) + Expect(clusterContext.String).To(Equal("my-cluster-context")) + }) + + It("sets cluster_context to NULL when empty", func() { + cfg := &config.Config{ + Namespace: "test-ns", + ClusterContext: "", + } + execID, _, err := auditor.StartExecution(ctx, "run", cfg) + Expect(err).NotTo(HaveOccurred()) + + db := auditor.DB() + var clusterContext sql.NullString + err = db.QueryRow(`SELECT cluster_context FROM audit_log WHERE id = ?`, execID).Scan(&clusterContext) + Expect(err).NotTo(HaveOccurred()) + Expect(clusterContext.Valid).To(BeFalse()) + }) + + It("records 'in-cluster' context for in-cluster execution", func() { + cfg := &config.Config{ + Namespace: "test-ns", + ClusterContext: "in-cluster", + } + execID, _, err := auditor.StartExecution(ctx, "run", cfg) + Expect(err).NotTo(HaveOccurred()) + + db := auditor.DB() + var clusterContext string + err = db.QueryRow(`SELECT cluster_context FROM audit_log WHERE id = ?`, execID).Scan(&clusterContext) + Expect(err).NotTo(HaveOccurred()) + Expect(clusterContext).To(Equal("in-cluster")) + }) + }) + + Describe("cleanup mode tracking", func() { + It("records cleanup_mode='all' for unfiltered cleanup", func() { + cfg := &config.Config{ + Namespace: "test-ns", + CleanupMode: "all", + } + execID, _, err := auditor.StartExecution(ctx, "cleanup", cfg) + Expect(err).NotTo(HaveOccurred()) + + db := auditor.DB() + var cleanupMode sql.NullString + err = db.QueryRow(`SELECT cleanup_mode FROM audit_log WHERE id = ?`, execID).Scan(&cleanupMode) + Expect(err).NotTo(HaveOccurred()) + Expect(cleanupMode.Valid).To(BeTrue()) + Expect(cleanupMode.String).To(Equal("all")) + }) + + It("records cleanup_mode='run-id' for filtered cleanup", func() { + cfg := &config.Config{ + Namespace: "test-ns", + CleanupMode: "run-id", + } + execID, _, err := auditor.StartExecution(ctx, "cleanup", cfg) + Expect(err).NotTo(HaveOccurred()) + + db := auditor.DB() + var cleanupMode string + err = db.QueryRow(`SELECT cleanup_mode FROM audit_log WHERE id = ?`, execID).Scan(&cleanupMode) + Expect(err).NotTo(HaveOccurred()) + Expect(cleanupMode).To(Equal("run-id")) + }) + + It("records cleanup_mode='dry-run' for dry-run cleanup", func() { + cfg := &config.Config{ + Namespace: "test-ns", + CleanupMode: "dry-run", + DryRun: true, + } + execID, _, err := auditor.StartExecution(ctx, "cleanup --dry-run", cfg) + Expect(err).NotTo(HaveOccurred()) + + db := auditor.DB() + var cleanupMode string + err = db.QueryRow(`SELECT cleanup_mode FROM audit_log WHERE id = ?`, execID).Scan(&cleanupMode) + Expect(err).NotTo(HaveOccurred()) + Expect(cleanupMode).To(Equal("dry-run")) + }) + + It("sets cleanup_mode to NULL for run commands", func() { + cfg := &config.Config{ + Namespace: "test-ns", + CleanupMode: "", + } + execID, _, err := auditor.StartExecution(ctx, "run", cfg) + Expect(err).NotTo(HaveOccurred()) + + db := auditor.DB() + var cleanupMode sql.NullString + err = db.QueryRow(`SELECT cleanup_mode FROM audit_log WHERE id = ?`, execID).Scan(&cleanupMode) + Expect(err).NotTo(HaveOccurred()) + Expect(cleanupMode.Valid).To(BeFalse()) + }) + }) }) var _ = Describe("NoOpAuditor", func() { diff --git a/internal/audit/schema.go b/internal/audit/schema.go index 856251f..e1c71cf 100644 --- a/internal/audit/schema.go +++ b/internal/audit/schema.go @@ -54,9 +54,7 @@ CREATE TABLE IF NOT EXISTS workload_details ( has_data_disk INTEGER NOT NULL DEFAULT 0, data_disk_size TEXT, requires_service INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'pending', - started_at TEXT, - completed_at TEXT + status TEXT NOT NULL DEFAULT 'pending' ); CREATE INDEX IF NOT EXISTS idx_workload_details_audit_id ON workload_details(audit_id); diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index d18fdef..c28b447 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -30,25 +30,42 @@ func NewScheme() *runtime.Scheme { // in-cluster configuration; on failure it falls back to the kubeconfig at // the given path (checking the KUBECONFIG env var when the path is empty). // Both failures produce a wrapped error. -func Connect(kubeconfigPath string) (client.Client, error) { +func Connect(kubeconfigPath string) (client.Client, string, error) { scheme := NewScheme() + var contextName string if kubeconfigPath == "" { kubeconfigPath = os.Getenv("KUBECONFIG") } restConfig, err := rest.InClusterConfig() if err != nil { - restConfig, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath) + // Not in-cluster, load from kubeconfig + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + if kubeconfigPath != "" { + loadingRules.ExplicitPath = kubeconfigPath + } + configOverrides := &clientcmd.ConfigOverrides{} + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + restConfig, err = kubeConfig.ClientConfig() if err != nil { - return nil, fmt.Errorf("failed to build kubeconfig from %q: %w", kubeconfigPath, err) + return nil, "", fmt.Errorf("failed to build kubeconfig from %q: %w", kubeconfigPath, err) + } + + // Get the current context name + rawConfig, err := kubeConfig.RawConfig() + if err == nil { + contextName = rawConfig.CurrentContext } + } else { + // In-cluster - no context name + contextName = "in-cluster" } c, err := client.New(restConfig, client.Options{Scheme: scheme}) if err != nil { - return nil, fmt.Errorf("failed to create controller-runtime client: %w", err) + return nil, "", fmt.Errorf("failed to create controller-runtime client: %w", err) } - return c, nil + return c, contextName, nil } diff --git a/internal/cluster/cluster_integration_test.go b/internal/cluster/cluster_integration_test.go index 6ceb596..d3116ef 100644 --- a/internal/cluster/cluster_integration_test.go +++ b/internal/cluster/cluster_integration_test.go @@ -26,13 +26,13 @@ func kubeconfigPath() string { var _ = Describe("Connect [integration]", func() { It("should connect using the configured kubeconfig", func() { - c, err := cluster.Connect(kubeconfigPath()) + c, _, err := cluster.Connect(kubeconfigPath()) Expect(err).NotTo(HaveOccurred()) Expect(c).NotTo(BeNil()) }) It("should return a functional client that can list namespaces", func() { - c, err := cluster.Connect(kubeconfigPath()) + c, _, err := cluster.Connect(kubeconfigPath()) Expect(err).NotTo(HaveOccurred()) nsList := &corev1.NamespaceList{} @@ -42,7 +42,7 @@ var _ = Describe("Connect [integration]", func() { }) It("should register KubeVirt types", func() { - c, err := cluster.Connect(kubeconfigPath()) + c, _, err := cluster.Connect(kubeconfigPath()) Expect(err).NotTo(HaveOccurred()) // Listing VMs should not return a "no kind registered" error. @@ -53,7 +53,7 @@ var _ = Describe("Connect [integration]", func() { }) It("should return error for invalid kubeconfig path", func() { - _, err := cluster.Connect("/nonexistent/kubeconfig") + _, _, err := cluster.Connect("/nonexistent/kubeconfig") Expect(err).To(HaveOccurred()) }) }) diff --git a/internal/cluster/cluster_test.go b/internal/cluster/cluster_test.go index 8dfd876..7f7a8bc 100644 --- a/internal/cluster/cluster_test.go +++ b/internal/cluster/cluster_test.go @@ -56,7 +56,7 @@ var _ = Describe("Connect", func() { } }() - _, err := cluster.Connect("/nonexistent/kubeconfig/path") + _, _, err := cluster.Connect("/nonexistent/kubeconfig/path") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("kubeconfig")) }) @@ -99,8 +99,9 @@ users: }() // Connect should succeed (client creation doesn't dial the server) - c, err := cluster.Connect(tmpFile.Name()) + c, contextName, err := cluster.Connect(tmpFile.Name()) Expect(err).NotTo(HaveOccurred()) Expect(c).NotTo(BeNil()) + Expect(contextName).NotTo(BeEmpty()) }) }) diff --git a/internal/config/config.go b/internal/config/config.go index 9d4a1dc..6f22055 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,6 +32,7 @@ type Config struct { Memory string `mapstructure:"memory"` Workloads map[string]WorkloadConfig `mapstructure:"workloads"` KubeconfigPath string `mapstructure:"kubeconfig"` + ClusterContext string // Runtime-only: current kubeconfig context CleanupMode string `mapstructure:"cleanup-mode"` WaitForReady bool `mapstructure:"wait-for-ready"` ReadyTimeoutSeconds int `mapstructure:"timeout"` diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index ca53739..4cde149 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -66,7 +66,7 @@ func MustConnect(kubeconfigPath string) client.Client { if kubeconfigPath == "" { kubeconfigPath = os.Getenv("KUBECONFIG") } - c, err := cluster.Connect(kubeconfigPath) + c, _, err := cluster.Connect(kubeconfigPath) if err != nil { panic(fmt.Sprintf("testutil.MustConnect: %v", err)) }