Files
at-container-registry/pkg/hold/pds/repomgr.go
T

1367 lines
32 KiB
Go

// Package pds contains a vendored copy of RepoManager from github.com/bluesky-social/indigo
//
// Source: github.com/bluesky-social/indigo/repomgr (v0.0.0-20251014222321)
// Reference: github.com/streamplace/indigo (67ae5a5) for PutRecord implementation
// Reason: The indigo library is unmaintained and contains a critical bug in UpdateRecord
//
// Modifications from original:
// - Changed package from 'repomgr' to 'pds' for integration with hold service
// - Fixed UpdateRecord bug (line 263): Changed r.PutRecord to r.UpdateRecord
// (UpdateRecord was incorrectly calling PutRecord, causing incorrect MST operations)
// - Removed 5 Prometheus metrics calls (openAndSigCheckDuration, calcDiffDuration,
// writeCarSliceDuration, repoOpsImported) as metrics are not used in this project
// - Added PutRecord method (lines 309-381) for creating records with explicit rkeys
// (like CreateRecord but with specified rkey instead of auto-generated TID)
// Based on streamplace/indigo implementation
package pds
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"sync"
holddb "atcr.io/pkg/hold/db"
atproto "github.com/bluesky-social/indigo/api/atproto"
bsky "github.com/bluesky-social/indigo/api/bsky"
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/bluesky-social/indigo/models"
"github.com/bluesky-social/indigo/mst"
"github.com/bluesky-social/indigo/repo"
"github.com/bluesky-social/indigo/util"
blocks "github.com/ipfs/go-block-format"
"github.com/ipfs/go-cid"
"github.com/ipfs/go-datastore"
blockstore "github.com/ipfs/go-ipfs-blockstore"
ipld "github.com/ipfs/go-ipld-format"
"github.com/ipld/go-car"
cbg "github.com/whyrusleeping/cbor-gen"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"gorm.io/gorm"
)
func NewRepoManager(cs holddb.CarStore, kmgr KeyManager) *RepoManager {
clk := syntax.NewTIDClock(0)
return &RepoManager{
cs: cs,
userLocks: make(map[models.Uid]*userLock),
kmgr: kmgr,
log: slog.Default().With("system", "repomgr"),
noArchive: false, // NonArchivalCarstore not used in hold service
clk: clk,
}
}
type KeyManager interface {
VerifyUserSignature(context.Context, string, []byte, []byte) error
SignForUser(context.Context, string, []byte) ([]byte, error)
}
func (rm *RepoManager) SetEventHandler(cb func(context.Context, *RepoEvent), hydrateRecords bool) {
rm.events = cb
rm.hydrateRecords = hydrateRecords
}
type RepoManager struct {
cs holddb.CarStore
kmgr KeyManager
lklk sync.Mutex
userLocks map[models.Uid]*userLock
events func(context.Context, *RepoEvent)
hydrateRecords bool
log *slog.Logger
noArchive bool
clk *syntax.TIDClock
}
type ActorInfo struct {
Did string
Handle string
DisplayName string
Type string
}
type RepoEvent struct {
User models.Uid
OldRoot *cid.Cid
NewRoot cid.Cid
Since *string
Rev string
RepoSlice []byte
PDS uint
Ops []RepoOp
}
type RepoOp struct {
Kind EventKind
Collection string
Rkey string
RecCid *cid.Cid
Record any
ActorInfo *ActorInfo
}
type EventKind string
const (
EvtKindCreateRecord = EventKind("create")
EvtKindUpdateRecord = EventKind("update")
EvtKindDeleteRecord = EventKind("delete")
)
type RepoHead struct {
gorm.Model
Usr models.Uid `gorm:"uniqueIndex"`
Root string
}
type userLock struct {
lk sync.Mutex
count int
}
func (rm *RepoManager) lockUser(ctx context.Context, user models.Uid) func() {
_, span := otel.Tracer("repoman").Start(ctx, "userLock")
defer span.End()
rm.lklk.Lock()
ulk, ok := rm.userLocks[user]
if !ok {
ulk = &userLock{}
rm.userLocks[user] = ulk
}
ulk.count++
rm.lklk.Unlock()
ulk.lk.Lock()
return func() {
rm.lklk.Lock()
ulk.lk.Unlock()
ulk.count--
if ulk.count == 0 {
delete(rm.userLocks, user)
}
rm.lklk.Unlock()
}
}
func (rm *RepoManager) CarStore() holddb.CarStore {
return rm.cs
}
func (rm *RepoManager) CreateRecord(ctx context.Context, user models.Uid, collection string, rec cbg.CBORMarshaler) (string, cid.Cid, error) {
ctx, span := otel.Tracer("repoman").Start(ctx, "CreateRecord")
defer span.End()
unlock := rm.lockUser(ctx, user)
defer unlock()
rev, err := rm.cs.GetUserRepoRev(ctx, user)
if err != nil {
return "", cid.Undef, err
}
ds, err := rm.cs.NewDeltaSession(ctx, user, &rev)
if err != nil {
return "", cid.Undef, err
}
head := ds.BaseCid()
r, err := repo.OpenRepo(ctx, ds, head)
if err != nil {
return "", cid.Undef, err
}
cc, tid, err := r.CreateRecord(ctx, collection, rec)
if err != nil {
return "", cid.Undef, err
}
nroot, nrev, err := r.Commit(ctx, rm.kmgr.SignForUser)
if err != nil {
return "", cid.Undef, err
}
rslice, err := ds.CloseWithRoot(ctx, nroot, nrev)
if err != nil {
return "", cid.Undef, fmt.Errorf("close with root: %w", err)
}
var oldroot *cid.Cid
if head.Defined() {
oldroot = &head
}
if rm.events != nil {
rm.events(ctx, &RepoEvent{
User: user,
OldRoot: oldroot,
NewRoot: nroot,
Rev: nrev,
Since: &rev,
Ops: []RepoOp{{
Kind: EvtKindCreateRecord,
Collection: collection,
Rkey: tid,
Record: rec,
RecCid: &cc,
}},
RepoSlice: rslice,
})
}
return collection + "/" + tid, cc, nil
}
func (rm *RepoManager) UpdateRecord(ctx context.Context, user models.Uid, collection, rkey string, rec cbg.CBORMarshaler) (cid.Cid, error) {
ctx, span := otel.Tracer("repoman").Start(ctx, "UpdateRecord")
defer span.End()
unlock := rm.lockUser(ctx, user)
defer unlock()
rev, err := rm.cs.GetUserRepoRev(ctx, user)
if err != nil {
return cid.Undef, err
}
ds, err := rm.cs.NewDeltaSession(ctx, user, &rev)
if err != nil {
return cid.Undef, err
}
head := ds.BaseCid()
r, err := repo.OpenRepo(ctx, ds, head)
if err != nil {
return cid.Undef, err
}
rpath := collection + "/" + rkey
cc, err := r.UpdateRecord(ctx, rpath, rec)
if err != nil {
return cid.Undef, err
}
nroot, nrev, err := r.Commit(ctx, rm.kmgr.SignForUser)
if err != nil {
return cid.Undef, err
}
rslice, err := ds.CloseWithRoot(ctx, nroot, nrev)
if err != nil {
return cid.Undef, fmt.Errorf("close with root: %w", err)
}
var oldroot *cid.Cid
if head.Defined() {
oldroot = &head
}
if rm.events != nil {
op := RepoOp{
Kind: EvtKindUpdateRecord,
Collection: collection,
Rkey: rkey,
RecCid: &cc,
}
if rm.hydrateRecords {
op.Record = rec
}
rm.events(ctx, &RepoEvent{
User: user,
OldRoot: oldroot,
NewRoot: nroot,
Rev: nrev,
Since: &rev,
Ops: []RepoOp{op},
RepoSlice: rslice,
})
}
return cc, nil
}
// PutRecord creates a record with an explicit rkey (like CreateRecord but with specified rkey).
// This uses r.PutRecord which will FAIL if the rkey already exists (uses mst.Add, not mst.Update).
// Use UpdateRecord to modify existing records.
// Returns the collection path (e.g., "io.atcr.captain/self") and CID.
func (rm *RepoManager) PutRecord(ctx context.Context, user models.Uid, collection, rkey string, rec cbg.CBORMarshaler) (string, cid.Cid, error) {
ctx, span := otel.Tracer("repoman").Start(ctx, "PutRecord")
defer span.End()
unlock := rm.lockUser(ctx, user)
defer unlock()
rev, err := rm.cs.GetUserRepoRev(ctx, user)
if err != nil {
return "", cid.Undef, err
}
ds, err := rm.cs.NewDeltaSession(ctx, user, &rev)
if err != nil {
return "", cid.Undef, err
}
head := ds.BaseCid()
r, err := repo.OpenRepo(ctx, ds, head)
if err != nil {
return "", cid.Undef, err
}
rpath := collection + "/" + rkey
cc, err := r.PutRecord(ctx, rpath, rec)
if err != nil {
return "", cid.Undef, err
}
nroot, nrev, err := r.Commit(ctx, rm.kmgr.SignForUser)
if err != nil {
return "", cid.Undef, err
}
rslice, err := ds.CloseWithRoot(ctx, nroot, nrev)
if err != nil {
return "", cid.Undef, fmt.Errorf("close with root: %w", err)
}
var oldroot *cid.Cid
if head.Defined() {
oldroot = &head
}
if rm.events != nil {
op := RepoOp{
Kind: EvtKindCreateRecord,
Collection: collection,
Rkey: rkey,
RecCid: &cc,
}
if rm.hydrateRecords {
op.Record = rec
}
rm.events(ctx, &RepoEvent{
User: user,
OldRoot: oldroot,
NewRoot: nroot,
Rev: nrev,
Since: &rev,
Ops: []RepoOp{op},
RepoSlice: rslice,
})
}
return rpath, cc, nil
}
// UpsertRecord creates or updates a record with an explicit rkey.
// If the record doesn't exist, it creates it. If it exists, it updates it.
// Returns the collection path (e.g., "io.atcr.captain/self"), CID, and whether it was created (true) or updated (false).
func (rm *RepoManager) UpsertRecord(ctx context.Context, user models.Uid, collection, rkey string, rec cbg.CBORMarshaler) (string, cid.Cid, bool, error) {
ctx, span := otel.Tracer("repoman").Start(ctx, "UpsertRecord")
defer span.End()
unlock := rm.lockUser(ctx, user)
defer unlock()
rev, err := rm.cs.GetUserRepoRev(ctx, user)
if err != nil {
return "", cid.Undef, false, err
}
ds, err := rm.cs.NewDeltaSession(ctx, user, &rev)
if err != nil {
return "", cid.Undef, false, err
}
head := ds.BaseCid()
r, err := repo.OpenRepo(ctx, ds, head)
if err != nil {
return "", cid.Undef, false, err
}
rpath := collection + "/" + rkey
// Check if record exists
_, _, err = r.GetRecordBytes(ctx, rpath)
recordExists := err == nil
var cc cid.Cid
var evtKind EventKind
if recordExists {
// Update existing record
cc, err = r.UpdateRecord(ctx, rpath, rec)
evtKind = EvtKindUpdateRecord
} else {
// Create new record
cc, err = r.PutRecord(ctx, rpath, rec)
evtKind = EvtKindCreateRecord
}
if err != nil {
return "", cid.Undef, false, err
}
nroot, nrev, err := r.Commit(ctx, rm.kmgr.SignForUser)
if err != nil {
return "", cid.Undef, false, err
}
rslice, err := ds.CloseWithRoot(ctx, nroot, nrev)
if err != nil {
return "", cid.Undef, false, fmt.Errorf("close with root: %w", err)
}
var oldroot *cid.Cid
if head.Defined() {
oldroot = &head
}
if rm.events != nil {
op := RepoOp{
Kind: evtKind,
Collection: collection,
Rkey: rkey,
RecCid: &cc,
}
if rm.hydrateRecords {
op.Record = rec
}
rm.events(ctx, &RepoEvent{
User: user,
OldRoot: oldroot,
NewRoot: nroot,
Rev: nrev,
Since: &rev,
Ops: []RepoOp{op},
RepoSlice: rslice,
})
}
return rpath, cc, !recordExists, nil
}
func (rm *RepoManager) DeleteRecord(ctx context.Context, user models.Uid, collection, rkey string) error {
ctx, span := otel.Tracer("repoman").Start(ctx, "DeleteRecord")
defer span.End()
unlock := rm.lockUser(ctx, user)
defer unlock()
rev, err := rm.cs.GetUserRepoRev(ctx, user)
if err != nil {
return err
}
ds, err := rm.cs.NewDeltaSession(ctx, user, &rev)
if err != nil {
return err
}
head := ds.BaseCid()
r, err := repo.OpenRepo(ctx, ds, head)
if err != nil {
return err
}
rpath := collection + "/" + rkey
if err := r.DeleteRecord(ctx, rpath); err != nil {
return err
}
nroot, nrev, err := r.Commit(ctx, rm.kmgr.SignForUser)
if err != nil {
return err
}
rslice, err := ds.CloseWithRoot(ctx, nroot, nrev)
if err != nil {
return fmt.Errorf("close with root: %w", err)
}
var oldroot *cid.Cid
if head.Defined() {
oldroot = &head
}
if rm.events != nil {
rm.events(ctx, &RepoEvent{
User: user,
OldRoot: oldroot,
NewRoot: nroot,
Rev: nrev,
Since: &rev,
Ops: []RepoOp{{
Kind: EvtKindDeleteRecord,
Collection: collection,
Rkey: rkey,
}},
RepoSlice: rslice,
})
}
return nil
}
func (rm *RepoManager) InitNewActor(ctx context.Context, user models.Uid, handle, did, displayname string, declcid, actortype string) error {
unlock := rm.lockUser(ctx, user)
defer unlock()
if did == "" {
return fmt.Errorf("must specify DID for new actor")
}
if user == 0 {
return fmt.Errorf("must specify user for new actor")
}
ds, err := rm.cs.NewDeltaSession(ctx, user, nil)
if err != nil {
return fmt.Errorf("creating new delta session: %w", err)
}
r := repo.NewRepo(ctx, did, ds)
profile := &bsky.ActorProfile{
DisplayName: &displayname,
}
_, err = r.PutRecord(ctx, "app.bsky.actor.profile/self", profile)
if err != nil {
return fmt.Errorf("setting initial actor profile: %w", err)
}
root, nrev, err := r.Commit(ctx, rm.kmgr.SignForUser)
if err != nil {
return fmt.Errorf("committing repo for actor init: %w", err)
}
rslice, err := ds.CloseWithRoot(ctx, root, nrev)
if err != nil {
return fmt.Errorf("close with root: %w", err)
}
if rm.events != nil {
op := RepoOp{
Kind: EvtKindCreateRecord,
Collection: "app.bsky.actor.profile",
Rkey: "self",
}
if rm.hydrateRecords {
op.Record = profile
}
rm.events(ctx, &RepoEvent{
User: user,
NewRoot: root,
Rev: nrev,
Ops: []RepoOp{op},
RepoSlice: rslice,
})
}
return nil
}
func (rm *RepoManager) GetRepoRoot(ctx context.Context, user models.Uid) (cid.Cid, error) {
unlock := rm.lockUser(ctx, user)
defer unlock()
return rm.cs.GetUserRepoHead(ctx, user)
}
func (rm *RepoManager) GetRepoRev(ctx context.Context, user models.Uid) (string, error) {
unlock := rm.lockUser(ctx, user)
defer unlock()
return rm.cs.GetUserRepoRev(ctx, user)
}
func (rm *RepoManager) ReadRepo(ctx context.Context, user models.Uid, since string, w io.Writer) error {
return rm.cs.ReadUserCar(ctx, user, since, true, w)
}
func (rm *RepoManager) GetRecord(ctx context.Context, user models.Uid, collection string, rkey string, maybeCid cid.Cid) (cid.Cid, cbg.CBORMarshaler, error) {
bs, err := rm.cs.ReadOnlySession(user)
if err != nil {
return cid.Undef, nil, err
}
head, err := rm.cs.GetUserRepoHead(ctx, user)
if err != nil {
return cid.Undef, nil, err
}
r, err := repo.OpenRepo(ctx, bs, head)
if err != nil {
return cid.Undef, nil, err
}
ocid, val, err := r.GetRecord(ctx, collection+"/"+rkey)
if err != nil {
return cid.Undef, nil, err
}
if maybeCid.Defined() && ocid != maybeCid {
return cid.Undef, nil, fmt.Errorf("record at specified key had different CID than expected")
}
return ocid, val, nil
}
func (rm *RepoManager) GetRecordProof(ctx context.Context, user models.Uid, collection string, rkey string) (cid.Cid, []blocks.Block, error) {
robs, err := rm.cs.ReadOnlySession(user)
if err != nil {
return cid.Undef, nil, err
}
bs := util.NewLoggingBstore(robs)
head, err := rm.cs.GetUserRepoHead(ctx, user)
if err != nil {
return cid.Undef, nil, err
}
r, err := repo.OpenRepo(ctx, bs, head)
if err != nil {
return cid.Undef, nil, err
}
_, _, err = r.GetRecordBytes(ctx, collection+"/"+rkey)
if err != nil {
return cid.Undef, nil, err
}
return head, bs.GetLoggedBlocks(), nil
}
func (rm *RepoManager) GetProfile(ctx context.Context, uid models.Uid) (*bsky.ActorProfile, error) {
bs, err := rm.cs.ReadOnlySession(uid)
if err != nil {
return nil, err
}
head, err := rm.cs.GetUserRepoHead(ctx, uid)
if err != nil {
return nil, err
}
r, err := repo.OpenRepo(ctx, bs, head)
if err != nil {
return nil, err
}
_, val, err := r.GetRecord(ctx, "app.bsky.actor.profile/self")
if err != nil {
return nil, err
}
ap, ok := val.(*bsky.ActorProfile)
if !ok {
return nil, fmt.Errorf("found wrong type in actor profile location in tree")
}
return ap, nil
}
func (rm *RepoManager) CheckRepoSig(ctx context.Context, r *repo.Repo, expdid string) error {
ctx, span := otel.Tracer("repoman").Start(ctx, "CheckRepoSig")
defer span.End()
repoDid := r.RepoDid()
if expdid != repoDid {
return fmt.Errorf("DID in repo did not match (%q != %q)", expdid, repoDid)
}
scom := r.SignedCommit()
usc := scom.Unsigned()
sb, err := usc.BytesForSigning()
if err != nil {
return fmt.Errorf("commit serialization failed: %w", err)
}
if err := rm.kmgr.VerifyUserSignature(ctx, repoDid, scom.Sig, sb); err != nil {
return fmt.Errorf("signature check failed (sig: %x) (sb: %x) : %w", scom.Sig, sb, err)
}
return nil
}
func (rm *RepoManager) HandleExternalUserEvent(ctx context.Context, pdsid uint, uid models.Uid, did string, since *string, nrev string, carslice []byte, ops []*atproto.SyncSubscribeRepos_RepoOp) error {
if rm.noArchive {
return rm.handleExternalUserEventNoArchive(ctx, pdsid, uid, did, since, nrev, carslice, ops)
} else {
return rm.handleExternalUserEventArchive(ctx, pdsid, uid, did, since, nrev, carslice, ops)
}
}
func (rm *RepoManager) handleExternalUserEventNoArchive(ctx context.Context, pdsid uint, uid models.Uid, did string, since *string, nrev string, carslice []byte, ops []*atproto.SyncSubscribeRepos_RepoOp) error {
ctx, span := otel.Tracer("repoman").Start(ctx, "HandleExternalUserEvent")
defer span.End()
span.SetAttributes(attribute.Int64("uid", int64(uid)))
rm.log.Debug("HandleExternalUserEvent", "pds", pdsid, "uid", uid, "since", since, "nrev", nrev)
unlock := rm.lockUser(ctx, uid)
defer unlock()
root, ds, err := rm.cs.ImportSlice(ctx, uid, since, carslice)
if err != nil {
return fmt.Errorf("importing external carslice: %w", err)
}
r, err := repo.OpenRepo(ctx, ds, root)
if err != nil {
return fmt.Errorf("opening external user repo (%d, root=%s): %w", uid, root, err)
}
if err := rm.CheckRepoSig(ctx, r, did); err != nil {
return fmt.Errorf("check repo sig: %w", err)
}
evtops := make([]RepoOp, 0, len(ops))
for _, op := range ops {
parts := strings.SplitN(op.Path, "/", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid rpath in mst diff, must have collection and rkey")
}
switch EventKind(op.Action) {
case EvtKindCreateRecord:
rop := RepoOp{
Kind: EvtKindCreateRecord,
Collection: parts[0],
Rkey: parts[1],
RecCid: (*cid.Cid)(op.Cid),
}
if rm.hydrateRecords {
_, rec, err := r.GetRecord(ctx, op.Path)
if err != nil {
return fmt.Errorf("reading changed record from car slice: %w", err)
}
rop.Record = rec
}
evtops = append(evtops, rop)
case EvtKindUpdateRecord:
rop := RepoOp{
Kind: EvtKindUpdateRecord,
Collection: parts[0],
Rkey: parts[1],
RecCid: (*cid.Cid)(op.Cid),
}
if rm.hydrateRecords {
_, rec, err := r.GetRecord(ctx, op.Path)
if err != nil {
return fmt.Errorf("reading changed record from car slice: %w", err)
}
rop.Record = rec
}
evtops = append(evtops, rop)
case EvtKindDeleteRecord:
evtops = append(evtops, RepoOp{
Kind: EvtKindDeleteRecord,
Collection: parts[0],
Rkey: parts[1],
})
default:
return fmt.Errorf("unrecognized external user event kind: %q", op.Action)
}
}
if rm.events != nil {
rm.events(ctx, &RepoEvent{
User: uid,
//OldRoot: prev,
NewRoot: root,
Rev: nrev,
Since: since,
Ops: evtops,
RepoSlice: carslice,
PDS: pdsid,
})
}
return nil
}
func (rm *RepoManager) handleExternalUserEventArchive(ctx context.Context, pdsid uint, uid models.Uid, did string, since *string, nrev string, carslice []byte, ops []*atproto.SyncSubscribeRepos_RepoOp) error {
ctx, span := otel.Tracer("repoman").Start(ctx, "HandleExternalUserEvent")
defer span.End()
span.SetAttributes(attribute.Int64("uid", int64(uid)))
rm.log.Debug("HandleExternalUserEvent", "pds", pdsid, "uid", uid, "since", since, "nrev", nrev)
unlock := rm.lockUser(ctx, uid)
defer unlock()
root, ds, err := rm.cs.ImportSlice(ctx, uid, since, carslice)
if err != nil {
return fmt.Errorf("importing external carslice: %w", err)
}
r, err := repo.OpenRepo(ctx, ds, root)
if err != nil {
return fmt.Errorf("opening external user repo (%d, root=%s): %w", uid, root, err)
}
if err := rm.CheckRepoSig(ctx, r, did); err != nil {
return err
}
var skipcids map[cid.Cid]bool
if ds.BaseCid().Defined() {
oldrepo, err := repo.OpenRepo(ctx, ds, ds.BaseCid())
if err != nil {
return fmt.Errorf("failed to check data root in old repo: %w", err)
}
// if the old commit has a 'prev', CalcDiff will error out while trying
// to walk it. This is an old repo thing that is being deprecated.
// This check is a temporary workaround until all repos get migrated
// and this becomes no longer an issue
prev, _ := oldrepo.PrevCommit(ctx)
if prev != nil {
skipcids = map[cid.Cid]bool{
*prev: true,
}
}
}
if err := ds.CalcDiff(ctx, skipcids); err != nil {
return fmt.Errorf("failed while calculating mst diff (since=%v): %w", since, err)
}
evtops := make([]RepoOp, 0, len(ops))
for _, op := range ops {
parts := strings.SplitN(op.Path, "/", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid rpath in mst diff, must have collection and rkey")
}
switch EventKind(op.Action) {
case EvtKindCreateRecord:
rop := RepoOp{
Kind: EvtKindCreateRecord,
Collection: parts[0],
Rkey: parts[1],
RecCid: (*cid.Cid)(op.Cid),
}
if rm.hydrateRecords {
_, rec, err := r.GetRecord(ctx, op.Path)
if err != nil {
return fmt.Errorf("reading changed record from car slice: %w", err)
}
rop.Record = rec
}
evtops = append(evtops, rop)
case EvtKindUpdateRecord:
rop := RepoOp{
Kind: EvtKindUpdateRecord,
Collection: parts[0],
Rkey: parts[1],
RecCid: (*cid.Cid)(op.Cid),
}
if rm.hydrateRecords {
_, rec, err := r.GetRecord(ctx, op.Path)
if err != nil {
return fmt.Errorf("reading changed record from car slice: %w", err)
}
rop.Record = rec
}
evtops = append(evtops, rop)
case EvtKindDeleteRecord:
evtops = append(evtops, RepoOp{
Kind: EvtKindDeleteRecord,
Collection: parts[0],
Rkey: parts[1],
})
default:
return fmt.Errorf("unrecognized external user event kind: %q", op.Action)
}
}
rslice, err := ds.CloseWithRoot(ctx, root, nrev)
if err != nil {
return fmt.Errorf("close with root: %w", err)
}
if rm.events != nil {
rm.events(ctx, &RepoEvent{
User: uid,
//OldRoot: prev,
NewRoot: root,
Rev: nrev,
Since: since,
Ops: evtops,
RepoSlice: rslice,
PDS: pdsid,
})
}
return nil
}
func (rm *RepoManager) BatchWrite(ctx context.Context, user models.Uid, writes []*atproto.RepoApplyWrites_Input_Writes_Elem) error {
ctx, span := otel.Tracer("repoman").Start(ctx, "BatchWrite")
defer span.End()
unlock := rm.lockUser(ctx, user)
defer unlock()
rev, err := rm.cs.GetUserRepoRev(ctx, user)
if err != nil {
return err
}
ds, err := rm.cs.NewDeltaSession(ctx, user, &rev)
if err != nil {
return err
}
head := ds.BaseCid()
r, err := repo.OpenRepo(ctx, ds, head)
if err != nil {
return err
}
ops := make([]RepoOp, 0, len(writes))
for _, w := range writes {
switch {
case w.RepoApplyWrites_Create != nil:
c := w.RepoApplyWrites_Create
var rkey string
if c.Rkey != nil {
rkey = *c.Rkey
} else {
rkey = rm.clk.Next().String()
}
nsid := c.Collection + "/" + rkey
cc, err := r.PutRecord(ctx, nsid, c.Value.Val)
if err != nil {
return err
}
op := RepoOp{
Kind: EvtKindCreateRecord,
Collection: c.Collection,
Rkey: rkey,
RecCid: &cc,
}
if rm.hydrateRecords {
op.Record = c.Value.Val
}
ops = append(ops, op)
case w.RepoApplyWrites_Update != nil:
u := w.RepoApplyWrites_Update
cc, err := r.PutRecord(ctx, u.Collection+"/"+u.Rkey, u.Value.Val)
if err != nil {
return err
}
op := RepoOp{
Kind: EvtKindUpdateRecord,
Collection: u.Collection,
Rkey: u.Rkey,
RecCid: &cc,
}
if rm.hydrateRecords {
op.Record = u.Value.Val
}
ops = append(ops, op)
case w.RepoApplyWrites_Delete != nil:
d := w.RepoApplyWrites_Delete
if err := r.DeleteRecord(ctx, d.Collection+"/"+d.Rkey); err != nil {
return err
}
ops = append(ops, RepoOp{
Kind: EvtKindDeleteRecord,
Collection: d.Collection,
Rkey: d.Rkey,
})
default:
return fmt.Errorf("no operation set in write enum")
}
}
nroot, nrev, err := r.Commit(ctx, rm.kmgr.SignForUser)
if err != nil {
return err
}
rslice, err := ds.CloseWithRoot(ctx, nroot, nrev)
if err != nil {
return fmt.Errorf("close with root: %w", err)
}
var oldroot *cid.Cid
if head.Defined() {
oldroot = &head
}
if rm.events != nil {
rm.events(ctx, &RepoEvent{
User: user,
OldRoot: oldroot,
NewRoot: nroot,
RepoSlice: rslice,
Rev: nrev,
Since: &rev,
Ops: ops,
})
}
return nil
}
func (rm *RepoManager) ImportNewRepo(ctx context.Context, user models.Uid, repoDid string, r io.Reader, rev *string) error {
ctx, span := otel.Tracer("repoman").Start(ctx, "ImportNewRepo")
defer span.End()
unlock := rm.lockUser(ctx, user)
defer unlock()
currev, err := rm.cs.GetUserRepoRev(ctx, user)
if err != nil {
return err
}
curhead, err := rm.cs.GetUserRepoHead(ctx, user)
if err != nil {
return err
}
if rev != nil && *rev == "" {
rev = nil
}
if rev == nil {
// if 'rev' is nil, this implies a fresh sync.
// in this case, ignore any existing blocks we have and treat this like a clean import.
curhead = cid.Undef
}
if rev != nil && *rev != currev {
// TODO: we could probably just deal with this
return fmt.Errorf("ImportNewRepo called with incorrect base")
}
err = rm.processNewRepo(ctx, user, r, rev, func(ctx context.Context, root cid.Cid, finish func(context.Context, string) ([]byte, error), bs blockstore.Blockstore) error {
r, err := repo.OpenRepo(ctx, bs, root)
if err != nil {
return fmt.Errorf("opening new repo: %w", err)
}
scom := r.SignedCommit()
usc := scom.Unsigned()
sb, err := usc.BytesForSigning()
if err != nil {
return fmt.Errorf("commit serialization failed: %w", err)
}
if err := rm.kmgr.VerifyUserSignature(ctx, repoDid, scom.Sig, sb); err != nil {
return fmt.Errorf("new user signature check failed: %w", err)
}
diffops, err := r.DiffSince(ctx, curhead)
if err != nil {
return fmt.Errorf("diff trees (curhead: %s): %w", curhead, err)
}
ops := make([]RepoOp, 0, len(diffops))
for _, op := range diffops {
out, err := rm.processOp(ctx, bs, op, rm.hydrateRecords)
if err != nil {
rm.log.Error("failed to process repo op", "err", err, "path", op.Rpath, "repo", repoDid)
}
if out != nil {
ops = append(ops, *out)
}
}
slice, err := finish(ctx, scom.Rev)
if err != nil {
return err
}
if rm.events != nil {
rm.events(ctx, &RepoEvent{
User: user,
//OldRoot: oldroot,
NewRoot: root,
Rev: scom.Rev,
Since: &currev,
RepoSlice: slice,
Ops: ops,
})
}
return nil
})
if err != nil {
return fmt.Errorf("process new repo (current rev: %s): %w", currev, err)
}
return nil
}
func (rm *RepoManager) processOp(ctx context.Context, bs blockstore.Blockstore, op *mst.DiffOp, hydrateRecords bool) (*RepoOp, error) {
parts := strings.SplitN(op.Rpath, "/", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("repo mst had invalid rpath: %q", op.Rpath)
}
switch op.Op {
case "add", "mut":
kind := EvtKindCreateRecord
if op.Op == "mut" {
kind = EvtKindUpdateRecord
}
outop := &RepoOp{
Kind: kind,
Collection: parts[0],
Rkey: parts[1],
RecCid: &op.NewCid,
}
if hydrateRecords {
blk, err := bs.Get(ctx, op.NewCid)
if err != nil {
return nil, err
}
rec, err := lexutil.CborDecodeValue(blk.RawData())
if err != nil {
if !errors.Is(err, lexutil.ErrUnrecognizedType) {
return nil, err
}
rm.log.Warn("failed processing repo diff", "err", err)
} else {
outop.Record = rec
}
}
return outop, nil
case "del":
return &RepoOp{
Kind: EvtKindDeleteRecord,
Collection: parts[0],
Rkey: parts[1],
RecCid: nil,
}, nil
default:
return nil, fmt.Errorf("diff returned invalid op type: %q", op.Op)
}
}
func (rm *RepoManager) processNewRepo(ctx context.Context, user models.Uid, r io.Reader, rev *string, cb func(ctx context.Context, root cid.Cid, finish func(context.Context, string) ([]byte, error), bs blockstore.Blockstore) error) error {
ctx, span := otel.Tracer("repoman").Start(ctx, "processNewRepo")
defer span.End()
carr, err := car.NewCarReader(r)
if err != nil {
return err
}
if len(carr.Header.Roots) != 1 {
return fmt.Errorf("invalid car file, header must have a single root (has %d)", len(carr.Header.Roots))
}
membs := blockstore.NewBlockstore(datastore.NewMapDatastore())
for {
blk, err := carr.Next()
if err != nil {
if err == io.EOF {
break
}
return err
}
if err := membs.Put(ctx, blk); err != nil {
return err
}
}
seen := make(map[cid.Cid]bool)
root := carr.Header.Roots[0]
// TODO: if there are blocks that get convergently recreated throughout
// the repos lifecycle, this will end up erroneously not including
// them. We should compute the set of blocks needed to read any repo
// ops that happened in the commit and use that for our 'output' blocks
cids, err := rm.walkTree(ctx, seen, root, membs, true)
if err != nil {
return fmt.Errorf("walkTree: %w", err)
}
ds, err := rm.cs.NewDeltaSession(ctx, user, rev)
if err != nil {
return fmt.Errorf("opening delta session: %w", err)
}
for _, c := range cids {
blk, err := membs.Get(ctx, c)
if err != nil {
return fmt.Errorf("copying walked cids to carstore: %w", err)
}
if err := ds.Put(ctx, blk); err != nil {
return err
}
}
finish := func(ctx context.Context, nrev string) ([]byte, error) {
return ds.CloseWithRoot(ctx, root, nrev)
}
if err := cb(ctx, root, finish, ds); err != nil {
return fmt.Errorf("cb errored root: %s, rev: %s: %w", root, stringOrNil(rev), err)
}
return nil
}
func stringOrNil(s *string) string {
if s == nil {
return "nil"
}
return *s
}
// walkTree returns all cids linked recursively by the root, skipping any cids
// in the 'skip' map, and not erroring on 'not found' if prevMissing is set
func (rm *RepoManager) walkTree(ctx context.Context, skip map[cid.Cid]bool, root cid.Cid, bs blockstore.Blockstore, prevMissing bool) ([]cid.Cid, error) {
// TODO: what if someone puts non-cbor links in their repo?
if root.Prefix().Codec != cid.DagCBOR {
return nil, fmt.Errorf("can only handle dag-cbor objects in repos (%s is %d)", root, root.Prefix().Codec)
}
blk, err := bs.Get(ctx, root)
if err != nil {
return nil, err
}
var links []cid.Cid
if err := cbg.ScanForLinks(bytes.NewReader(blk.RawData()), func(c cid.Cid) {
if c.Prefix().Codec == cid.Raw {
rm.log.Debug("skipping 'raw' CID in record", "recordCid", root, "rawCid", c)
return
}
if skip[c] {
return
}
links = append(links, c)
skip[c] = true
}); err != nil {
return nil, err
}
out := []cid.Cid{root}
skip[root] = true
// TODO: should do this non-recursive since i expect these may get deep
for _, c := range links {
sub, err := rm.walkTree(ctx, skip, c, bs, prevMissing)
if err != nil {
if prevMissing && !ipld.IsNotFound(err) {
return nil, err
}
}
out = append(out, sub...)
}
return out, nil
}
func (rm *RepoManager) TakeDownRepo(ctx context.Context, uid models.Uid) error {
unlock := rm.lockUser(ctx, uid)
defer unlock()
return rm.cs.WipeUserData(ctx, uid)
}
// ResetRepo is technically identical to TakeDownRepo, for now
func (rm *RepoManager) ResetRepo(ctx context.Context, uid models.Uid) error {
unlock := rm.lockUser(ctx, uid)
defer unlock()
return rm.cs.WipeUserData(ctx, uid)
}
func (rm *RepoManager) VerifyRepo(ctx context.Context, uid models.Uid) error {
ses, err := rm.cs.ReadOnlySession(uid)
if err != nil {
return err
}
r, err := repo.OpenRepo(ctx, ses, ses.BaseCid())
if err != nil {
return err
}
if err := r.ForEach(ctx, "", func(k string, v cid.Cid) error {
_, err := ses.Get(ctx, v)
if err != nil {
return fmt.Errorf("failed to get record %s (%s): %w", k, v, err)
}
return nil
}); err != nil {
return err
}
return nil
}