mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 12:17:00 +00:00
178 lines
4.9 KiB
Go
178 lines
4.9 KiB
Go
// Package labeler provides a labeler subscription client for the hold service.
|
|
//
|
|
// The hold subscribes to one labeler and mirrors active takedowns into a local
|
|
// cache (in-memory + SQLite). On takedown receipt the hold purges layer, scan,
|
|
// and image-config records for the affected manifest. The cache is consulted
|
|
// by the GC to gate blob deletion: while a takedown is within its grace window,
|
|
// the manifest's blob digests stay in the GC's referenced set so reversal can
|
|
// still restore them.
|
|
package labeler
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Cache holds active takedown URIs and their creation timestamps. It is
|
|
// thread-safe and persists state to SQLite so the hold can answer takedown
|
|
// queries before the labeler subscription has caught up after a restart.
|
|
type Cache struct {
|
|
mu sync.RWMutex
|
|
// manifest URI → cts. Includes both per-record URIs (at://did/coll/rkey)
|
|
// and per-DID URIs (at://did) for user-level takedowns.
|
|
entries map[string]time.Time
|
|
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewCache opens (or creates) the takedown_cache and labeler_cursor tables on
|
|
// the given DB and loads any existing entries into memory.
|
|
func NewCache(db *sql.DB) (*Cache, error) {
|
|
c := &Cache{
|
|
entries: make(map[string]time.Time),
|
|
db: db,
|
|
}
|
|
|
|
stmts := []string{
|
|
`CREATE TABLE IF NOT EXISTS takedown_cache (
|
|
uri TEXT PRIMARY KEY,
|
|
src TEXT NOT NULL,
|
|
cts TIMESTAMP NOT NULL
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_takedown_cache_cts ON takedown_cache(cts)`,
|
|
`CREATE TABLE IF NOT EXISTS labeler_cursor (
|
|
labeler_did TEXT PRIMARY KEY,
|
|
cursor INTEGER NOT NULL
|
|
)`,
|
|
}
|
|
for _, s := range stmts {
|
|
if _, err := db.Exec(s); err != nil {
|
|
return nil, fmt.Errorf("init labeler schema: %w", err)
|
|
}
|
|
}
|
|
|
|
rows, err := db.Query(`SELECT uri, cts FROM takedown_cache`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load takedown cache: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var uri string
|
|
var cts time.Time
|
|
if err := rows.Scan(&uri, &cts); err != nil {
|
|
return nil, fmt.Errorf("scan takedown_cache row: %w", err)
|
|
}
|
|
c.entries[uri] = cts
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// Set records a positive takedown for uri at cts. Idempotent: re-applying
|
|
// updates the timestamp (newer takedowns win).
|
|
func (c *Cache) Set(uri, src string, cts time.Time) error {
|
|
c.mu.Lock()
|
|
c.entries[uri] = cts
|
|
c.mu.Unlock()
|
|
|
|
_, err := c.db.Exec(
|
|
`INSERT INTO takedown_cache (uri, src, cts) VALUES (?, ?, ?)
|
|
ON CONFLICT(uri) DO UPDATE SET src = excluded.src, cts = excluded.cts`,
|
|
uri, src, cts,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("persist takedown: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Negate removes a takedown entry. Idempotent.
|
|
func (c *Cache) Negate(uri string) error {
|
|
c.mu.Lock()
|
|
delete(c.entries, uri)
|
|
c.mu.Unlock()
|
|
|
|
_, err := c.db.Exec(`DELETE FROM takedown_cache WHERE uri = ?`, uri)
|
|
if err != nil {
|
|
return fmt.Errorf("delete takedown: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IsTakenDown reports whether a manifest URI is taken down, either directly
|
|
// (per-manifest takedown) or via a user-level takedown on its DID. The returned
|
|
// timestamp is the earliest cts that applies (i.e. the longest-standing
|
|
// takedown), which is what the grace check should compare against.
|
|
func (c *Cache) IsTakenDown(manifestURI string) (cts time.Time, ok bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
if t, has := c.entries[manifestURI]; has {
|
|
cts = t
|
|
ok = true
|
|
}
|
|
|
|
// User-level: at://<did> with no path, applies to every record by that DID.
|
|
did := didFromManifestURI(manifestURI)
|
|
if did != "" {
|
|
userURI := "at://" + did
|
|
if t, has := c.entries[userURI]; has {
|
|
if !ok || t.Before(cts) {
|
|
cts = t
|
|
ok = true
|
|
}
|
|
}
|
|
}
|
|
return cts, ok
|
|
}
|
|
|
|
// IsExpired returns true if cts is older than the grace window.
|
|
func IsExpired(cts time.Time, graceWindow time.Duration) bool {
|
|
if graceWindow <= 0 {
|
|
return true
|
|
}
|
|
return time.Since(cts) > graceWindow
|
|
}
|
|
|
|
// GetCursor returns the last persisted cursor for a labeler DID (0 if none).
|
|
func (c *Cache) GetCursor(labelerDID string) (int64, error) {
|
|
var cursor int64
|
|
err := c.db.QueryRow(`SELECT cursor FROM labeler_cursor WHERE labeler_did = ?`, labelerDID).Scan(&cursor)
|
|
if err == sql.ErrNoRows {
|
|
return 0, nil
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("read labeler cursor: %w", err)
|
|
}
|
|
return cursor, nil
|
|
}
|
|
|
|
// SetCursor persists the cursor for a labeler DID.
|
|
func (c *Cache) SetCursor(labelerDID string, cursor int64) error {
|
|
_, err := c.db.Exec(
|
|
`INSERT INTO labeler_cursor (labeler_did, cursor) VALUES (?, ?)
|
|
ON CONFLICT(labeler_did) DO UPDATE SET cursor = excluded.cursor`,
|
|
labelerDID, cursor,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("persist labeler cursor: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// didFromManifestURI extracts the authority (DID) from an at:// URI.
|
|
// Returns "" for malformed input.
|
|
func didFromManifestURI(uri string) string {
|
|
const prefix = "at://"
|
|
if !strings.HasPrefix(uri, prefix) {
|
|
return ""
|
|
}
|
|
rest := uri[len(prefix):]
|
|
if before, _, ok := strings.Cut(rest, "/"); ok {
|
|
return before
|
|
}
|
|
return rest
|
|
}
|