- ids: TestIDsAreTimeOrdered asserted strict lexicographic ordering of back-to-back UUIDv7s, but the sub-ms tail is random and not required to be monotonic. Sleep between samples so each ID lands in a distinct millisecond — the property that actually gives Postgres index locality on (org_id, id desc). - go.mod/go.sum: run go mod tidy. keyfunc/v3, prometheus/client_golang and testcontainers-go/modules/postgres are imported directly and should not be marked // indirect; also drops stale sum entries. - gofmt -w across 12 files flagged by the lint job. - security.yml: pin govulncheck to v1.2.0. @latest triggers a proxy lookup every run, which is the step that hung for 16m on the Gitea runner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
295 lines
10 KiB
Go
295 lines
10 KiB
Go
// Package store defines the interfaces anchorage uses to reach its
|
|
// source-of-truth Postgres database. Every domain package (org, pin,
|
|
// token, ...) depends on the interface here, never on the concrete
|
|
// pgx-backed implementation in store/postgres. This keeps the domain
|
|
// layer testable with in-memory fakes.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"anchorage/internal/pkg/ids"
|
|
)
|
|
|
|
// ErrNotFound is the canonical "row not present" sentinel. Concrete
|
|
// implementations translate driver-specific errors (pgx.ErrNoRows, etc.)
|
|
// into this.
|
|
var ErrNotFound = errors.New("store: not found")
|
|
|
|
// ErrConflict is returned when a write violates a uniqueness invariant
|
|
// the caller should handle (duplicate slug, duplicate email, existing
|
|
// live pin with the same org+cid).
|
|
var ErrConflict = errors.New("store: conflict")
|
|
|
|
// Store is the root aggregate interface. A single implementation pins
|
|
// all sub-stores to the same Postgres pool and transaction context so
|
|
// composite operations can be atomic.
|
|
type Store interface {
|
|
Orgs() OrgStore
|
|
Users() UserStore
|
|
Memberships() MembershipStore
|
|
Tokens() TokenStore
|
|
Nodes() NodeStore
|
|
Pins() PinStore
|
|
Audit() AuditStore
|
|
|
|
// WithOrgContext returns a context whose queries run with the
|
|
// anchorage.org_id GUC set, so Postgres RLS applies. The returned
|
|
// context MUST be passed to every subsequent Store call inside the
|
|
// same request so the GUC is honored. Concrete implementations use
|
|
// it to set the GUC per pool connection / transaction.
|
|
WithOrgContext(ctx context.Context, orgID ids.OrgID) (context.Context, error)
|
|
|
|
// Tx runs fn inside a Postgres transaction. The closure receives a
|
|
// tx-scoped context (so per-statement cancellation stays tied to the
|
|
// tx's lifetime and any GUC set on the tx is honored) and a Store
|
|
// whose sub-stores share that single tx handle.
|
|
Tx(ctx context.Context, fn func(txCtx context.Context, tx Store) error) error
|
|
}
|
|
|
|
// Org is the domain-level organisation record.
|
|
type Org struct {
|
|
ID ids.OrgID
|
|
Slug string
|
|
Name string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// OrgStore holds organisations.
|
|
type OrgStore interface {
|
|
Create(ctx context.Context, id ids.OrgID, slug, name string) (*Org, error)
|
|
GetByID(ctx context.Context, id ids.OrgID) (*Org, error)
|
|
GetBySlug(ctx context.Context, slug string) (*Org, error)
|
|
UpdateName(ctx context.Context, id ids.OrgID, name string) (*Org, error)
|
|
List(ctx context.Context, limit, offset int) ([]*Org, error)
|
|
}
|
|
|
|
// User is the domain-level user record.
|
|
type User struct {
|
|
ID ids.UserID
|
|
AuthentikSub string
|
|
Email string
|
|
DisplayName string
|
|
IsSysadmin bool
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// UserStore holds users.
|
|
type UserStore interface {
|
|
UpsertByAuthentikSub(ctx context.Context, id ids.UserID, sub, email, displayName string, isSysadmin bool) (*User, error)
|
|
GetByID(ctx context.Context, id ids.UserID) (*User, error)
|
|
GetByEmail(ctx context.Context, email string) (*User, error)
|
|
PromoteSysadmin(ctx context.Context, id ids.UserID) error
|
|
}
|
|
|
|
// Membership pairs a user with an org + role.
|
|
type Membership struct {
|
|
OrgID ids.OrgID
|
|
UserID ids.UserID
|
|
Role string // "orgadmin" | "member"
|
|
OrgSlug string // populated by List*, empty on raw gets
|
|
OrgName string
|
|
Created time.Time
|
|
}
|
|
|
|
// Role constants.
|
|
const (
|
|
RoleOrgAdmin = "orgadmin"
|
|
RoleMember = "member"
|
|
)
|
|
|
|
// MembershipStore manages the user↔org join.
|
|
type MembershipStore interface {
|
|
Add(ctx context.Context, orgID ids.OrgID, userID ids.UserID, role string) error
|
|
Remove(ctx context.Context, orgID ids.OrgID, userID ids.UserID) error
|
|
ListForUser(ctx context.Context, userID ids.UserID) ([]*Membership, error)
|
|
}
|
|
|
|
// APIToken is the metadata record for an issued JWT. The signed JWT
|
|
// value itself is never stored — revocation is handled via the denylist.
|
|
type APIToken struct {
|
|
JTI ids.TokenID
|
|
OrgID ids.OrgID
|
|
UserID ids.UserID
|
|
Label string
|
|
Scopes []string
|
|
ExpiresAt time.Time
|
|
RevokedAt *time.Time
|
|
LastUsedAt *time.Time
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// TokenStore manages API token metadata and the denylist.
|
|
type TokenStore interface {
|
|
Create(ctx context.Context, t *APIToken) error
|
|
GetByJTI(ctx context.Context, jti ids.TokenID) (*APIToken, error)
|
|
ListForUser(ctx context.Context, orgID ids.OrgID, userID ids.UserID) ([]*APIToken, error)
|
|
Revoke(ctx context.Context, jti ids.TokenID) error
|
|
TouchLastUsed(ctx context.Context, jti ids.TokenID) error
|
|
|
|
AddDenylist(ctx context.Context, jti ids.TokenID, expiresAt time.Time, reason string) error
|
|
IsDenied(ctx context.Context, jti ids.TokenID) (bool, error)
|
|
PruneDenylist(ctx context.Context) error
|
|
}
|
|
|
|
// NodeStatus values.
|
|
const (
|
|
NodeStatusUp = "up"
|
|
NodeStatusDown = "down"
|
|
NodeStatusDrained = "drained"
|
|
)
|
|
|
|
// Node is a cluster member's registry entry.
|
|
type Node struct {
|
|
ID ids.NodeID
|
|
DisplayName string
|
|
Multiaddrs []string
|
|
RPCURL string
|
|
Status string
|
|
LastSeenAt time.Time
|
|
JoinedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// NodeStore manages the cluster registry.
|
|
type NodeStore interface {
|
|
Upsert(ctx context.Context, n *Node) error
|
|
Get(ctx context.Context, id ids.NodeID) (*Node, error)
|
|
ListAll(ctx context.Context) ([]*Node, error)
|
|
ListLive(ctx context.Context) ([]*Node, error)
|
|
TouchHeartbeat(ctx context.Context, id ids.NodeID) error
|
|
MarkStaleDown(ctx context.Context, staleAfter time.Duration) ([]ids.NodeID, error)
|
|
Drain(ctx context.Context, id ids.NodeID) error
|
|
Uncordon(ctx context.Context, id ids.NodeID) error
|
|
}
|
|
|
|
// PinStatus values (matching the IPFS Pinning API spec).
|
|
const (
|
|
PinStatusQueued = "queued"
|
|
PinStatusPinning = "pinning"
|
|
PinStatusPinned = "pinned"
|
|
PinStatusFailed = "failed"
|
|
)
|
|
|
|
// Pin is the logical pin record (what the API exposes as PinStatus).
|
|
type Pin struct {
|
|
RequestID ids.PinID
|
|
OrgID ids.OrgID
|
|
CID string
|
|
Name *string
|
|
Meta map[string]any
|
|
Origins []string
|
|
Status string
|
|
FailureReason *string
|
|
Created time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// Placement is the per-node scheduling row for a pin.
|
|
type Placement struct {
|
|
RequestID ids.PinID
|
|
NodeID ids.NodeID
|
|
Status string
|
|
FailureReason *string
|
|
Attempts int
|
|
Fence int64
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
// Multiaddrs is populated by joined reads; empty on raw fetches.
|
|
Multiaddrs []string
|
|
}
|
|
|
|
// PinFilter is the spec-compliant filter surface for GET /v1/pins.
|
|
//
|
|
// Every field is optional; empty values disable that clause. `Match` is
|
|
// interpreted only when `Name` is set:
|
|
//
|
|
// "exact" — case-sensitive equality
|
|
// "iexact" — case-insensitive equality (default when name is set)
|
|
// "partial" — case-sensitive substring
|
|
// "ipartial" — case-insensitive substring
|
|
//
|
|
// Before and After are both inclusive of their endpoint semantics:
|
|
// `after <= created < before`.
|
|
type PinFilter struct {
|
|
CIDs []string
|
|
Name string
|
|
Match string
|
|
Status []string
|
|
Before *time.Time
|
|
After *time.Time
|
|
Meta map[string]any
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
// PinStore manages pins and their placements + refcounts.
|
|
type PinStore interface {
|
|
Create(ctx context.Context, p *Pin) error
|
|
Get(ctx context.Context, orgID ids.OrgID, requestID ids.PinID) (*Pin, error)
|
|
// GetByRequestID fetches a pin without an org filter.
|
|
//
|
|
// This is used by the scheduler / rebalancer / sweeper which walk
|
|
// placements cluster-wide and need to look up the parent pin without
|
|
// already knowing its org. Do not use from the HTTP API path —
|
|
// routes there are tenant-scoped and must go through Get(orgID, rid).
|
|
GetByRequestID(ctx context.Context, requestID ids.PinID) (*Pin, error)
|
|
GetLiveByCID(ctx context.Context, orgID ids.OrgID, cid string) (*Pin, error)
|
|
UpdateStatus(ctx context.Context, requestID ids.PinID, status string, failureReason *string) error
|
|
Delete(ctx context.Context, orgID ids.OrgID, requestID ids.PinID) error
|
|
List(ctx context.Context, orgID ids.OrgID, limit, offset int) ([]*Pin, error)
|
|
// Filter runs the spec's filtered list. An empty PinFilter is
|
|
// equivalent to List with default pagination.
|
|
Filter(ctx context.Context, orgID ids.OrgID, f PinFilter) ([]*Pin, error)
|
|
// Replace swaps a pin's CID / name / meta / origins atomically.
|
|
// Caller is responsible for reshuffling placements + refcounts
|
|
// inside the same transaction (the CID change means the existing
|
|
// pin_refcount rows are stale).
|
|
Replace(ctx context.Context, orgID ids.OrgID, requestID ids.PinID, newCID string, name *string, meta map[string]any, origins []string) (*Pin, error)
|
|
|
|
InsertPlacement(ctx context.Context, requestID ids.PinID, nodeID ids.NodeID, fence int64) (*Placement, error)
|
|
GetPlacement(ctx context.Context, requestID ids.PinID, nodeID ids.NodeID) (*Placement, error)
|
|
ListPlacements(ctx context.Context, requestID ids.PinID) ([]*Placement, error)
|
|
ListPlacementsForNode(ctx context.Context, nodeID ids.NodeID, status string) ([]*Placement, error)
|
|
UpdatePlacementFenced(ctx context.Context, requestID ids.PinID, nodeID ids.NodeID, status string, failureReason *string, fence int64) (int64, error)
|
|
ReplacePlacement(ctx context.Context, requestID ids.PinID, oldNode, newNode ids.NodeID) (*Placement, error)
|
|
StuckPlacements(ctx context.Context, stuckAfter time.Duration) ([]Placement, error)
|
|
|
|
IncRefcount(ctx context.Context, nodeID ids.NodeID, cid string) error
|
|
DecRefcount(ctx context.Context, nodeID ids.NodeID, cid string) (int, error)
|
|
DeleteRefcountIfZero(ctx context.Context, nodeID ids.NodeID, cid string) error
|
|
|
|
// CountPlacementsByStatus returns a histogram of placement rows
|
|
// grouped by status. Used by the Prometheus gauge refresh loop;
|
|
// unqueried statuses are omitted from the map (a zero gauge is the
|
|
// Prometheus convention).
|
|
CountPlacementsByStatus(ctx context.Context) (map[string]int, error)
|
|
}
|
|
|
|
// AuditEntry is a single audit_log row.
|
|
type AuditEntry struct {
|
|
ID int64
|
|
OrgID *ids.OrgID
|
|
ActorUserID *ids.UserID
|
|
ActorTokenJTI *string
|
|
Action string
|
|
Target string
|
|
Result string // "ok" | "error"
|
|
Detail map[string]any
|
|
Created time.Time
|
|
}
|
|
|
|
// AuditStore is append-only. Writes must be fire-and-forget-safe —
|
|
// callers don't want audit failures to fail user requests, so the
|
|
// concrete implementation should log rather than return on error paths
|
|
// where appropriate.
|
|
type AuditStore interface {
|
|
Insert(ctx context.Context, e *AuditEntry) error
|
|
List(ctx context.Context, orgID ids.OrgID, limit, offset int) ([]*AuditEntry, error)
|
|
}
|