feat(02-01): add Store interface and SQLiteStore implementation

- Store interface with 9 methods covering all persistence operations
- SQLiteStore implements all 9 methods with exact SQL from current handlers
- NewSQLiteStore sets MaxOpenConns(1) and PRAGMA foreign_keys = ON
- UpsertEvent uses ON CONFLICT DO UPDATE with acknowledged_at reset to NULL
- AssignTag uses INSERT OR REPLACE for tag_assignments table
- golang-migrate v4.19.1 dependency added to go.mod
This commit is contained in:
2026-03-23 21:53:05 +01:00
parent 12cf34ce57
commit 57bf3bdfe5
4 changed files with 204 additions and 1 deletions

3
go.mod
View File

@@ -4,12 +4,13 @@ go 1.26
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.38.0 // indirect
modernc.org/libc v1.67.6 // indirect modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

4
go.sum
View File

@@ -1,5 +1,7 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -13,6 +15,8 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=

View File

@@ -0,0 +1,183 @@
package diunwebhook
import (
"database/sql"
"sync"
"time"
)
// SQLiteStore implements Store using a SQLite database.
type SQLiteStore struct {
db *sql.DB
mu sync.Mutex
}
// NewSQLiteStore creates a new SQLiteStore backed by the given *sql.DB.
// It sets MaxOpenConns(1) to prevent concurrent write contention and
// enables foreign key enforcement via PRAGMA foreign_keys = ON.
func NewSQLiteStore(db *sql.DB) *SQLiteStore {
db.SetMaxOpenConns(1)
// PRAGMA foreign_keys must be set per-connection; with MaxOpenConns(1) this covers all queries.
db.Exec("PRAGMA foreign_keys = ON") //nolint:errcheck
return &SQLiteStore{db: db}
}
// UpsertEvent inserts or updates a DIUN event in the updates table.
// On conflict (same image), all fields are updated and acknowledged_at is reset to NULL.
func (s *SQLiteStore) UpsertEvent(event DiunEvent) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.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),
)
return err
}
// GetUpdates returns all update entries joined with their tag assignments.
func (s *SQLiteStore) GetUpdates() (map[string]UpdateEntry, error) {
rows, err := s.db.Query(`SELECT u.image, u.diun_version, u.hostname, u.status, u.provider,
u.hub_link, u.mime_type, u.digest, u.created, u.platform,
u.ctn_name, u.ctn_id, u.ctn_state, u.ctn_status, u.received_at, COALESCE(u.acknowledged_at, ''),
t.id, t.name
FROM updates u
LEFT JOIN tag_assignments ta ON u.image = ta.image
LEFT JOIN tags t ON ta.tag_id = t.id`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]UpdateEntry)
for rows.Next() {
var e UpdateEntry
var createdStr, receivedStr, acknowledgedAt string
var tagID sql.NullInt64
var tagName sql.NullString
err := rows.Scan(&e.Event.Image, &e.Event.DiunVersion, &e.Event.Hostname,
&e.Event.Status, &e.Event.Provider, &e.Event.HubLink, &e.Event.MimeType,
&e.Event.Digest, &createdStr, &e.Event.Platform,
&e.Event.Metadata.ContainerName, &e.Event.Metadata.ContainerID,
&e.Event.Metadata.State, &e.Event.Metadata.Status,
&receivedStr, &acknowledgedAt, &tagID, &tagName)
if err != nil {
return nil, err
}
e.Event.Created, _ = time.Parse(time.RFC3339, createdStr)
e.ReceivedAt, _ = time.Parse(time.RFC3339, receivedStr)
e.Acknowledged = acknowledgedAt != ""
if tagID.Valid && tagName.Valid {
e.Tag = &Tag{ID: int(tagID.Int64), Name: tagName.String}
}
result[e.Event.Image] = e
}
return result, rows.Err()
}
// AcknowledgeUpdate marks the given image as acknowledged.
// Returns found=false if no row with that image exists.
func (s *SQLiteStore) AcknowledgeUpdate(image string) (found bool, err error) {
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(`UPDATE updates SET acknowledged_at = datetime('now') WHERE image = ?`, image)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// ListTags returns all tags ordered by name.
func (s *SQLiteStore) ListTags() ([]Tag, error) {
rows, err := s.db.Query(`SELECT id, name FROM tags ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
tags := []Tag{}
for rows.Next() {
var t Tag
if err := rows.Scan(&t.ID, &t.Name); err != nil {
return nil, err
}
tags = append(tags, t)
}
return tags, rows.Err()
}
// CreateTag inserts a new tag with the given name and returns the created tag.
func (s *SQLiteStore) CreateTag(name string) (Tag, error) {
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(`INSERT INTO tags (name) VALUES (?)`, name)
if err != nil {
return Tag{}, err
}
id, _ := res.LastInsertId()
return Tag{ID: int(id), Name: name}, nil
}
// DeleteTag deletes the tag with the given id.
// Returns found=false if no tag with that id exists.
func (s *SQLiteStore) DeleteTag(id int) (found bool, err error) {
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(`DELETE FROM tags WHERE id = ?`, id)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// AssignTag assigns the given image to the given tag.
// Uses INSERT OR REPLACE so re-assigning an image to a different tag replaces the existing assignment.
func (s *SQLiteStore) AssignTag(image string, tagID int) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`INSERT OR REPLACE INTO tag_assignments (image, tag_id) VALUES (?, ?)`, image, tagID)
return err
}
// UnassignTag removes any tag assignment for the given image.
func (s *SQLiteStore) UnassignTag(image string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM tag_assignments WHERE image = ?`, image)
return err
}
// TagExists returns true if a tag with the given id exists.
func (s *SQLiteStore) TagExists(id int) (bool, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM tags WHERE id = ?`, id).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}

15
pkg/diunwebhook/store.go Normal file
View File

@@ -0,0 +1,15 @@
package diunwebhook
// Store defines all persistence operations. Implementations must be safe
// for concurrent use from HTTP handlers.
type Store interface {
UpsertEvent(event DiunEvent) error
GetUpdates() (map[string]UpdateEntry, error)
AcknowledgeUpdate(image string) (found bool, err error)
ListTags() ([]Tag, error)
CreateTag(name string) (Tag, error)
DeleteTag(id int) (found bool, err error)
AssignTag(image string, tagID int) error
UnassignTag(image string) error
TagExists(id int) (bool, error)
}