From b7eaa4602f506f38b7854f903839181e978778a2 Mon Sep 17 00:00:00 2001 From: Melvin Hillsman Date: Sat, 23 May 2026 22:58:46 -0500 Subject: [PATCH] fix(audit): transition workload_details.status to failed on error Mark workload status as 'failed' when VM creation or readiness checks fail instead of leaving rows stuck at 'pending' forever. On creation failure all workloads are marked failed; on readiness failure only workloads with failed VMs are marked failed while others are marked created. Closes #75 Signed-off-by: Melvin Hillsman --- cmd/virtwork/main.go | 20 +++++++++++++ docs/audit-schema.md | 2 +- internal/audit/audit_test.go | 57 ++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/cmd/virtwork/main.go b/cmd/virtwork/main.go index 21fd7e0..407da5b 100644 --- a/cmd/virtwork/main.go +++ b/cmd/virtwork/main.go @@ -530,9 +530,18 @@ func runE(cmd *cobra.Command, args []string) error { }) } if err := g.Wait(); err != nil { + for _, wlID := range auditWorkloadIDs { + _ = auditor.UpdateWorkloadStatus(ctx, wlID, "failed") + } return fmt.Errorf("creating VMs: %w", err) } + // Build VM-name → workload-component mapping for readiness tracking + vmToComponent := make(map[string]string, len(plans)) + for _, p := range plans { + vmToComponent[p.vmName] = p.component + } + // Wait for readiness if cfg.WaitForReady { timeout := time.Duration(cfg.ReadyTimeoutSeconds) * time.Second @@ -543,6 +552,7 @@ func runE(cmd *cobra.Command, args []string) error { timeout, constants.DefaultPollInterval) failures := 0 + failedWorkloads := make(map[string]bool) for name, err := range results { if err != nil { logger.Error("vm readiness check failed", @@ -554,6 +564,9 @@ func runE(cmd *cobra.Command, args []string) error { Message: fmt.Sprintf("VM %s failed readiness check", name), ErrorDetail: err.Error(), }) + if comp, ok := vmToComponent[name]; ok { + failedWorkloads[comp] = true + } } else { _ = auditor.RecordEvent(ctx, execID, audit.EventRecord{ EventType: "vm_ready", @@ -562,6 +575,13 @@ func runE(cmd *cobra.Command, args []string) error { } } if failures > 0 { + for comp, wlID := range auditWorkloadIDs { + if failedWorkloads[comp] { + _ = auditor.UpdateWorkloadStatus(ctx, wlID, "failed") + } else { + _ = auditor.UpdateWorkloadStatus(ctx, wlID, "created") + } + } return fmt.Errorf("%d of %d VMs failed; %w", failures, len(vmNames), ErrReadinessCheck) } logger.Info("all VMs ready", slog.Int("vm_count", len(vmNames))) diff --git a/docs/audit-schema.md b/docs/audit-schema.md index 35c97b4..fbc154c 100644 --- a/docs/audit-schema.md +++ b/docs/audit-schema.md @@ -135,7 +135,7 @@ Indexes: `started_at`, `namespace`, `status`, `run_id`. | `has_data_disk` | INTEGER NOT NULL | 1 when `DataVolumeTemplates()` is non-empty | | `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` | +| `status` | TEXT NOT NULL | `pending` → `created` (after all VMs succeed) or `failed` (on VM creation or readiness error) | Indexes: `audit_id`, `workload_type`. diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index da1db32..9b5b4b5 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -562,3 +562,60 @@ var _ = Describe("NoOpAuditor", func() { Expect(a.Close()).To(Succeed()) }) }) + +var _ = Describe("Workload status transitions", func() { + var ( + auditor *audit.SQLiteAuditor + ctx context.Context + execID int64 + wlID int64 + ) + + BeforeEach(func() { + ctx = context.Background() + var err error + auditor, err = audit.NewSQLiteAuditor(":memory:") + Expect(err).NotTo(HaveOccurred()) + + cfg := &config.Config{Namespace: "test-ns"} + execID, _, err = auditor.StartExecution(ctx, "run", cfg) + Expect(err).NotTo(HaveOccurred()) + + wlID, err = auditor.RecordWorkload(ctx, execID, audit.WorkloadRecord{ + WorkloadType: "cpu", Enabled: true, VMCount: 1, CPUCores: 2, Memory: "2Gi", + }) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + Expect(auditor.Close()).To(Succeed()) + }) + + It("defaults to pending on insert", func() { + db := auditor.DB() + var status string + err := db.QueryRow(`SELECT status FROM workload_details WHERE id = ?`, wlID).Scan(&status) + Expect(err).NotTo(HaveOccurred()) + Expect(status).To(Equal("pending")) + }) + + It("transitions to created on success", func() { + Expect(auditor.UpdateWorkloadStatus(ctx, wlID, "created")).To(Succeed()) + + db := auditor.DB() + var status string + err := db.QueryRow(`SELECT status FROM workload_details WHERE id = ?`, wlID).Scan(&status) + Expect(err).NotTo(HaveOccurred()) + Expect(status).To(Equal("created")) + }) + + It("transitions to failed on orchestration error", func() { + Expect(auditor.UpdateWorkloadStatus(ctx, wlID, "failed")).To(Succeed()) + + db := auditor.DB() + var status string + err := db.QueryRow(`SELECT status FROM workload_details WHERE id = ?`, wlID).Scan(&status) + Expect(err).NotTo(HaveOccurred()) + Expect(status).To(Equal("failed")) + }) +})