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
46 changes: 32 additions & 14 deletions cmd/virtwork/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
8 changes: 3 additions & 5 deletions docs/audit-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 |
Expand All @@ -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` |
Expand Down Expand Up @@ -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`.

Expand Down
18 changes: 9 additions & 9 deletions internal/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}

Expand Down
112 changes: 112 additions & 0 deletions internal/audit/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
4 changes: 1 addition & 3 deletions internal/audit/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 22 additions & 5 deletions internal/cluster/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
8 changes: 4 additions & 4 deletions internal/cluster/cluster_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand All @@ -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.
Expand All @@ -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())
})
})
5 changes: 3 additions & 2 deletions internal/cluster/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
})
Expand Down Expand Up @@ -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())
})
})
Loading
Loading