Files
DiunDashboard/.planning/phases/01-data-integrity/01-01-PLAN.md

265 lines
12 KiB
Markdown

---
phase: 01-data-integrity
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- pkg/diunwebhook/diunwebhook.go
- pkg/diunwebhook/diunwebhook_test.go
autonomous: true
requirements:
- DATA-01
- DATA-02
must_haves:
truths:
- "A second DIUN event for an already-tagged image does not remove its tag assignment"
- "Deleting a tag removes all associated tag_assignments rows (ON DELETE CASCADE fires)"
- "The full test suite passes with no new failures introduced"
artifacts:
- path: "pkg/diunwebhook/diunwebhook.go"
provides: "UPSERT in UpdateEvent(); PRAGMA foreign_keys = ON in InitDB()"
contains: "ON CONFLICT(image) DO UPDATE SET"
- path: "pkg/diunwebhook/diunwebhook_test.go"
provides: "Regression test TestUpdateEvent_PreservesTagOnUpsert"
contains: "TestUpdateEvent_PreservesTagOnUpsert"
key_links:
- from: "InitDB()"
to: "PRAGMA foreign_keys = ON"
via: "db.Exec immediately after db.SetMaxOpenConns(1)"
pattern: "PRAGMA foreign_keys = ON"
- from: "UpdateEvent()"
to: "INSERT INTO updates ... ON CONFLICT(image) DO UPDATE SET"
via: "db.Exec with named column list"
pattern: "ON CONFLICT\\(image\\) DO UPDATE SET"
---
<objective>
Fix the two data-destruction bugs that are silently corrupting tag assignments today.
Bug 1 (DATA-01): `UpdateEvent()` uses `INSERT OR REPLACE` which SQLite implements as DELETE + INSERT. The DELETE fires the `ON DELETE CASCADE` on `tag_assignments.image`, destroying the child row. Every new DIUN event for an already-tagged image loses its tag.
Bug 2 (DATA-02): `PRAGMA foreign_keys = ON` is never executed. SQLite disables FK enforcement by default. The `ON DELETE CASCADE` on `tag_assignments.tag_id` does not fire when a tag is deleted.
These two bugs are fixed in the same plan because fixing DATA-01 without DATA-02 causes `TestDeleteTagHandler_CascadesAssignment` to break (tag assignments now survive UPSERT but FK cascades still do not fire on tag deletion).
Purpose: Users can trust that tagging an image is permanent until they explicitly remove it, and that deleting a tag group cleans up all assignments.
Output: Updated `diunwebhook.go` with UPSERT + FK pragma; new regression test `TestUpdateEvent_PreservesTagOnUpsert` in `diunwebhook_test.go`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-data-integrity/01-RESEARCH.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Replace INSERT OR REPLACE with UPSERT in UpdateEvent() and add PRAGMA FK enforcement in InitDB()</name>
<files>pkg/diunwebhook/diunwebhook.go</files>
<read_first>
- pkg/diunwebhook/diunwebhook.go — read the entire file before touching it; understand current InitDB() structure (lines 58-104) and UpdateEvent() structure (lines 106-118)
</read_first>
<behavior>
- Test 1 (existing, must still pass): TestDismissHandler_ReappearsAfterNewWebhook — a new webhook event resets acknowledged_at to NULL
- Test 2 (existing, must still pass): TestDeleteTagHandler_CascadesAssignment — deleting a tag removes the tag_assignment row (requires both UPSERT and PRAGMA fixes)
- Test 3 (new, added in Task 2): TestUpdateEvent_PreservesTagOnUpsert — tag survives a second UpdateEvent() for the same image
</behavior>
<action>
Make exactly two changes to pkg/diunwebhook/diunwebhook.go:
CHANGE 1 — Add PRAGMA to InitDB():
After line 64 (`db.SetMaxOpenConns(1)`), insert:
```go
if _, err = db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
return err
}
```
This must appear before any CREATE TABLE statement. The error must not be swallowed.
CHANGE 2 — Replace INSERT OR REPLACE in UpdateEvent():
Replace the entire db.Exec call at lines 109-116 (the `INSERT OR REPLACE INTO updates VALUES (...)` statement and its argument list) with:
```go
_, err := db.Exec(`
INSERT INTO updates (
image, diun_version, hostname, status, provider,
hub_link, mime_type, digest, created, platform,
ctn_name, ctn_id, ctn_state, ctn_status,
received_at, acknowledged_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL)
ON CONFLICT(image) DO UPDATE SET
diun_version = excluded.diun_version,
hostname = excluded.hostname,
status = excluded.status,
provider = excluded.provider,
hub_link = excluded.hub_link,
mime_type = excluded.mime_type,
digest = excluded.digest,
created = excluded.created,
platform = excluded.platform,
ctn_name = excluded.ctn_name,
ctn_id = excluded.ctn_id,
ctn_state = excluded.ctn_state,
ctn_status = excluded.ctn_status,
received_at = excluded.received_at,
acknowledged_at = NULL`,
event.Image, event.DiunVersion, event.Hostname, event.Status, event.Provider,
event.HubLink, event.MimeType, event.Digest,
event.Created.Format(time.RFC3339), event.Platform,
event.Metadata.ContainerName, event.Metadata.ContainerID,
event.Metadata.State, event.Metadata.Status,
time.Now().Format(time.RFC3339),
)
```
The column count (15 named columns + NULL for acknowledged_at = 16 positional `?` placeholders) must match the 15 bound arguments (acknowledged_at is hardcoded NULL, not a bound arg).
No other changes to diunwebhook.go in this task. Do not add imports — `errors` is not needed here.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -run "TestDismissHandler_ReappearsAfterNewWebhook|TestDeleteTagHandler_CascadesAssignment" ./pkg/diunwebhook/</automated>
</verify>
<done>
- pkg/diunwebhook/diunwebhook.go contains the string `PRAGMA foreign_keys = ON`
- pkg/diunwebhook/diunwebhook.go contains the string `ON CONFLICT(image) DO UPDATE SET`
- pkg/diunwebhook/diunwebhook.go does NOT contain `INSERT OR REPLACE INTO updates`
- TestDismissHandler_ReappearsAfterNewWebhook passes
- TestDeleteTagHandler_CascadesAssignment passes
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add regression test TestUpdateEvent_PreservesTagOnUpsert</name>
<files>pkg/diunwebhook/diunwebhook_test.go</files>
<read_first>
- pkg/diunwebhook/diunwebhook_test.go — read the entire file before touching it; the new test must follow the established patterns (httptest.NewRequest, diun.UpdatesReset(), postTagAndGetID helper, diun.GetUpdatesMap())
- pkg/diunwebhook/export_test.go — verify GetUpdatesMap() and UpdatesReset() signatures
</read_first>
<behavior>
- Test: First UpdateEvent() for "nginx:latest" → assign tag "webservers" via TagAssignmentHandler → second UpdateEvent() for "nginx:latest" with Status "update" → GetUpdatesMap()["nginx:latest"].Tag must be non-nil → Tag.ID must equal tagID → Acknowledged must be false
</behavior>
<action>
Add the following test function to pkg/diunwebhook/diunwebhook_test.go, appended after the existing TestGetUpdates_IncludesTag function (at the end of the file):
```go
func TestUpdateEvent_PreservesTagOnUpsert(t *testing.T) {
diun.UpdatesReset()
// Insert image
if err := diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest", Status: "new"}); err != nil {
t.Fatalf("first UpdateEvent failed: %v", err)
}
// Assign tag
tagID := postTagAndGetID(t, "webservers")
body, _ := json.Marshal(map[string]interface{}{"image": "nginx:latest", "tag_id": tagID})
req := httptest.NewRequest(http.MethodPut, "/api/tag-assignments", bytes.NewReader(body))
rec := httptest.NewRecorder()
diun.TagAssignmentHandler(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("tag assignment failed: got %d", rec.Code)
}
// Dismiss (acknowledge) the image — second event must reset this
req = httptest.NewRequest(http.MethodPatch, "/api/updates/nginx:latest", nil)
rec = httptest.NewRecorder()
diun.DismissHandler(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("dismiss failed: got %d", rec.Code)
}
// Receive a second event for the same image
if err := diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest", Status: "update"}); err != nil {
t.Fatalf("second UpdateEvent failed: %v", err)
}
// Tag must survive the second event
m := diun.GetUpdatesMap()
entry, ok := m["nginx:latest"]
if !ok {
t.Fatal("nginx:latest missing from updates after second event")
}
if entry.Tag == nil {
t.Error("tag was lost after second UpdateEvent — UPSERT bug not fixed")
}
if entry.Tag != nil && entry.Tag.ID != tagID {
t.Errorf("tag ID changed: expected %d, got %d", tagID, entry.Tag.ID)
}
// Acknowledged state must be reset by the new event
if entry.Acknowledged {
t.Error("acknowledged state must be reset by new event")
}
// Status must reflect the new event
if entry.Event.Status != "update" {
t.Errorf("expected status 'update', got %q", entry.Event.Status)
}
}
```
This test verifies all three observable behaviors from DATA-01:
1. Tag survives the UPSERT (the primary bug)
2. acknowledged_at is reset to NULL by the new event
3. Event fields (Status) are updated by the new event
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -run "TestUpdateEvent_PreservesTagOnUpsert" ./pkg/diunwebhook/</automated>
</verify>
<done>
- pkg/diunwebhook/diunwebhook_test.go contains the function `TestUpdateEvent_PreservesTagOnUpsert`
- TestUpdateEvent_PreservesTagOnUpsert passes (tag non-nil, ID matches, Acknowledged false, Status "update")
- Full test suite still passes: `go test ./pkg/diunwebhook/` exits 0
</done>
</task>
</tasks>
<verification>
Run the full test suite after both tasks are complete:
```bash
cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -coverprofile=coverage.out -coverpkg=./... ./...
```
Expected outcome:
- All existing tests pass (no regressions)
- TestUpdateEvent_PreservesTagOnUpsert passes
- TestDeleteTagHandler_CascadesAssignment passes (proves DATA-02)
Spot-check the fixes with grep:
```bash
grep -n "PRAGMA foreign_keys" pkg/diunwebhook/diunwebhook.go
grep -n "ON CONFLICT(image) DO UPDATE SET" pkg/diunwebhook/diunwebhook.go
grep -c "INSERT OR REPLACE INTO updates" pkg/diunwebhook/diunwebhook.go # must output 0
```
</verification>
<success_criteria>
- `grep -c "INSERT OR REPLACE INTO updates" pkg/diunwebhook/diunwebhook.go` outputs `0`
- `grep -c "PRAGMA foreign_keys = ON" pkg/diunwebhook/diunwebhook.go` outputs `1`
- `grep -c "ON CONFLICT(image) DO UPDATE SET" pkg/diunwebhook/diunwebhook.go` outputs `1`
- `go test ./pkg/diunwebhook/` exits 0
- `TestUpdateEvent_PreservesTagOnUpsert` exists in diunwebhook_test.go and passes
</success_criteria>
<output>
After completion, create `.planning/phases/01-data-integrity/01-01-SUMMARY.md` following the summary template at `@$HOME/.claude/get-shit-done/templates/summary.md`.
</output>