lightning-terminal/firewalldb/sql_migration.go
Viktor Tigerström 4ca77b4e2d
firewalldb: use sqlcmig6 for kvdb to sql migration
This commit updates the firewalldb package to use the new `sqlcmig6`
package for kvdb to SQL migration.
2026-02-13 19:37:31 +01:00

1631 lines
50 KiB
Go

package firewalldb
import (
"bytes"
"context"
"database/sql"
"encoding/binary"
"errors"
"fmt"
"reflect"
"sort"
"time"
"github.com/davecgh/go-spew/spew"
"github.com/lightninglabs/lightning-terminal/accounts"
"github.com/lightninglabs/lightning-terminal/db/sqlcmig6"
"github.com/lightninglabs/lightning-terminal/session"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/sqldb"
"github.com/pmezard/go-difflib/difflib"
"go.etcd.io/bbolt"
)
// kvEntry represents a single KV entry inserted into the BoltDB.
type kvEntry struct {
perm bool
ruleName string
key string
value []byte
// groupAlias is the legacy session group alias that the entry is
// associated with. For global entries, this will be fn.None[[]byte].
groupAlias fn.Option[[]byte]
// featureName is the name of the feature that the entry is associated
// with. If the entry is not feature specific, this will be
// fn.None[string].
featureName fn.Option[string]
}
// sqlKvEntry represents a single KV entry inserted into the SQL DB, containing
// the same fields as the kvEntry, but with additional fields that represent the
// SQL IDs of the rule, session group, and feature.
type sqlKvEntry struct {
*kvEntry
ruleID int64
// groupID is the sql session group ID that the entry is associated
// with. For global entries, this will be Valid=false.
groupID sql.NullInt64
// featureID is the sql feature ID that the entry is associated with.
// This is only set if the entry is feature specific, and will be
// Valid=false for other types entries. If this is set, then groupID
// will also be set.
featureID sql.NullInt64
}
// namespacedKey returns a string representation of the kvEntry purely used for
// logging purposes.
func (e *kvEntry) namespacedKey() string {
ns := fmt.Sprintf("perm: %t, rule: %s", e.perm, e.ruleName)
e.groupAlias.WhenSome(func(alias []byte) {
ns += fmt.Sprintf(", group: %s", alias)
})
e.featureName.WhenSome(func(feature string) {
ns += fmt.Sprintf(", feature: %s", feature)
})
ns += fmt.Sprintf(", key: %s", e.key)
return ns
}
// privacyPairs is a type alias for a map that holds the privacy pairs, where
// the outer key is the group ID, and the value is a map of real to pseudo
// values.
type privacyPairs = map[int64]map[string]string
// MigrateFirewallDBToSQL runs the migration of the firwalldb stores from the
// bbolt database to a SQL database. The migration is done in a single
// transaction to ensure that all rows in the stores are migrated or none at
// all.
//
// NOTE: As sessions may contain linked sessions and accounts, the sessions and
// accounts sql migration MUST be run prior to this migration.
func MigrateFirewallDBToSQL(ctx context.Context, kvStore *bbolt.DB,
sqlTx *sqlcmig6.Queries, macRootKeyIDs [][]byte) error {
log.Infof("Starting migration of the rules DB to SQL")
sessions, err := sqlTx.ListSessions(ctx)
if err != nil {
return fmt.Errorf("listing sessions failed: %w", err)
}
sessionMap, err := mapSessions(sessions)
if err != nil {
return fmt.Errorf("mapping sessions failed: %w", err)
}
err = migrateKVStoresDBToSQL(ctx, kvStore, sqlTx, sessionMap)
if err != nil {
return err
}
err = migratePrivacyMapperDBToSQL(ctx, kvStore, sqlTx, sessionMap)
if err != nil {
return err
}
err = migrateActionsToSQL(
ctx, kvStore, sqlTx, macRootKeyIDs, sessionMap,
)
if err != nil {
return err
}
log.Infof("The rules DB has been migrated from KV to SQL.")
return nil
}
// migrateKVStoresDBToSQL runs the migration of all KV stores from the KV
// database to the SQL database. The function also asserts that the
// migrated values match the original values in the KV store.
func migrateKVStoresDBToSQL(ctx context.Context, kvStore *bbolt.DB,
sqlTx *sqlcmig6.Queries, sessMap map[[4]byte]sqlcmig6.Session) error {
log.Infof("Starting migration of the KV stores to SQL")
var pairs []*kvEntry
// 1) Collect all key-value pairs from the KV store.
err := kvStore.View(func(tx *bbolt.Tx) error {
var err error
pairs, err = collectAllPairs(sessMap, tx)
return err
})
if err != nil {
return fmt.Errorf("collecting all kv pairs failed: %w", err)
}
var insertedPairs []*sqlKvEntry
// 2) Insert all collected key-value pairs into the SQL database.
for _, entry := range pairs {
insertedPair, err := insertPair(ctx, sqlTx, sessMap, entry)
if err != nil {
return fmt.Errorf("inserting kv pair %v failed: %w",
entry.key, err)
}
insertedPairs = append(insertedPairs, insertedPair)
}
// 3) Validate the migrated values against the original values.
for _, insertedPair := range insertedPairs {
// Fetch the appropriate SQL entry's value.
migratedValue, err := getSQLValue(ctx, sqlTx, insertedPair)
if err != nil {
return fmt.Errorf("getting SQL value for key %s "+
"failed: %w", insertedPair.namespacedKey(), err)
}
// Compare the value of the migrated entry with the original
// value from the KV store.
// NOTE: if the insert a []byte{} value into the sqldb as the
// entry value, and then retrieve it, the value will be
// returned as nil. The bytes.Equal will pass in that case,
// and therefore such cases won't error out. The kvdb instance
// can store []byte{} values.
if !bytes.Equal(migratedValue, insertedPair.value) {
return fmt.Errorf("migrated value for key %s "+
"does not match original value: "+
"migrated %x, original %x",
insertedPair.namespacedKey(), migratedValue,
insertedPair.value)
}
}
log.Infof("Migration of the KV stores to SQL completed. Total number "+
"of rows migrated: %d", len(pairs))
return nil
}
// collectAllPairs collects all key-value pairs from the KV store, and returns
// them as a slice of kvEntry structs. The function expects the KV store to be
// stuctured as described in the comment in the firewalldb/kvstores_kvdb.go
// file. Any other structure will result in an error.
// Note that this function and the subsequent functions are intentionally
// designed to iterate over all buckets and values that exist in the KV store.
// That ensures that we find all stores and values that exist in the KV store,
// and can be sure that the kv store actually follows the expected structure.
func collectAllPairs(sessMap map[[4]byte]sqlcmig6.Session,
tx *bbolt.Tx) ([]*kvEntry, error) {
var entries []*kvEntry
for _, perm := range []bool{true, false} {
mainBucket, err := getMainBucket(tx, false, perm)
if err != nil {
return nil, err
}
if mainBucket == nil {
// If the mainBucket doesn't exist, there are no entries
// to migrate under that bucket, therefore we don't
// error, and just proceed to not migrate any entries
// under that bucket.
continue
}
// Loop over each rule-name bucket.
err = mainBucket.ForEach(func(rule, v []byte) error {
if v != nil {
return errors.New("expected only " +
"buckets under main bucket")
}
ruleBucket := mainBucket.Bucket(rule)
if ruleBucket == nil {
return fmt.Errorf("rule bucket %s not found",
rule)
}
pairs, err := collectRulePairs(
sessMap, ruleBucket, perm, string(rule),
)
if err != nil {
return err
}
entries = append(entries, pairs...)
return nil
})
if err != nil {
return nil, err
}
}
return entries, nil
}
// collectRulePairs processes a single rule bucket, which should contain the
// global and session-kv-store key buckets.
func collectRulePairs(sessMap map[[4]byte]sqlcmig6.Session, bkt *bbolt.Bucket,
perm bool, rule string) ([]*kvEntry, error) {
var params []*kvEntry
err := verifyBktKeys(
bkt, true, globalKVStoreBucketKey, sessKVStoreBucketKey,
)
if err != nil {
return params, fmt.Errorf("verifying rule bucket %s keys "+
"failed: %w", rule, err)
}
if globalBkt := bkt.Bucket(globalKVStoreBucketKey); globalBkt != nil {
p, err := collectKVPairs(
globalBkt, true, perm, rule,
fn.None[[]byte](), fn.None[string](),
)
if err != nil {
return nil, fmt.Errorf("collecting global kv pairs "+
"failed: %w", err)
}
params = append(params, p...)
}
if sessBkt := bkt.Bucket(sessKVStoreBucketKey); sessBkt != nil {
err := sessBkt.ForEach(func(groupAlias, v []byte) error {
if v != nil {
return fmt.Errorf("expected only buckets "+
"under %s bucket", sessKVStoreBucketKey)
}
var alias [4]byte
copy(alias[:], groupAlias)
if _, ok := sessMap[alias]; !ok {
// If we can't find the session group in the
// SQL db, that indicates that the session was
// never migrated from KVDB. This likely means
// that the user deleted their session.db file,
// but kept the rules.db file. As the KV entries
// are useless when the session no longer
// exists, we can just skip the migration of the
// KV entries for this group.
log.Warnf("Skipping migration of KV store "+
"entries for session group %x, as the "+
"session group was not found",
groupAlias)
return nil
}
groupBucket := sessBkt.Bucket(groupAlias)
if groupBucket == nil {
return fmt.Errorf("group bucket for group "+
"alias %s not found", groupAlias)
}
kvPairs, err := collectKVPairs(
groupBucket, false, perm, rule,
fn.Some(groupAlias), fn.None[string](),
)
if err != nil {
return fmt.Errorf("collecting group kv "+
"pairs failed: %w", err)
}
params = append(params, kvPairs...)
err = verifyBktKeys(
groupBucket, false, featureKVStoreBucketKey,
)
if err != nil {
return fmt.Errorf("verification of group "+
"bucket %s keys failed: %w", groupAlias,
err)
}
ftBkt := groupBucket.Bucket(featureKVStoreBucketKey)
if ftBkt == nil {
return nil
}
return ftBkt.ForEach(func(ftName, v []byte) error {
if v != nil {
return fmt.Errorf("expected only "+
"buckets under %s bucket",
featureKVStoreBucketKey)
}
// The feature name should exist, as per the
// verification above.
featureBucket := ftBkt.Bucket(ftName)
if featureBucket == nil {
return fmt.Errorf("feature bucket "+
"%s not found", ftName)
}
featurePairs, err := collectKVPairs(
featureBucket, true, perm, rule,
fn.Some(groupAlias),
fn.Some(string(ftName)),
)
if err != nil {
return fmt.Errorf("collecting "+
"feature kv pairs failed: %w",
err)
}
params = append(params, featurePairs...)
return nil
})
})
if err != nil {
return nil, fmt.Errorf("collecting session kv pairs "+
"failed: %w", err)
}
}
return params, nil
}
// collectKVPairs collects all key-value pairs from the given bucket, and
// returns them as a slice of kvEntry structs. If the errorOnBuckets parameter
// is set to true, then the function will return an error if the bucket
// contains any sub-buckets. Note that when the errorOnBuckets parameter is
// set to false, the function will not collect any key-value pairs from the
// sub-buckets, and will just ignore them.
func collectKVPairs(bkt *bbolt.Bucket, errorOnBuckets, perm bool,
ruleName string, groupAlias fn.Option[[]byte],
featureName fn.Option[string]) ([]*kvEntry, error) {
var params []*kvEntry
return params, bkt.ForEach(func(key, value []byte) error {
// If the value is nil, then this is a bucket, which we
// don't want to process here, as we only want to collect
// the key-value pairs, not the buckets. If we should
// error on buckets, then we return an error here.
if value == nil {
if errorOnBuckets {
return fmt.Errorf("unexpected bucket %s found "+
"in when collecting kv pairs", key)
}
return nil
}
params = append(params, &kvEntry{
perm: perm,
ruleName: ruleName,
key: string(key),
featureName: featureName,
groupAlias: groupAlias,
value: value,
})
return nil
})
}
// insertPair inserts a single key-value pair into the SQL database.
func insertPair(ctx context.Context, tx *sqlcmig6.Queries,
sessMap map[[4]byte]sqlcmig6.Session,
entry *kvEntry) (*sqlKvEntry, error) {
ruleID, err := tx.GetOrInsertRuleID(ctx, entry.ruleName)
if err != nil {
return nil, err
}
p := sqlcmig6.InsertKVStoreRecordParams{
Perm: entry.perm,
RuleID: ruleID,
EntryKey: entry.key,
Value: entry.value,
}
entry.groupAlias.WhenSome(func(alias []byte) {
var groupAlias [4]byte
copy(groupAlias[:], alias)
sess, ok := sessMap[groupAlias]
if !ok {
// This should be unreachable, as we check for the
// existence of the session group when collecting
// the kv pairs.
err = fmt.Errorf("session group %x not found in map",
alias)
}
p.GroupID = sess.GroupID
})
if err != nil {
return nil, err
}
entry.featureName.WhenSome(func(feature string) {
var featureID int64
featureID, err = tx.GetOrInsertFeatureID(ctx, feature)
if err != nil {
err = fmt.Errorf("getting/inserting feature id for %s "+
"failed: %w", feature, err)
return
}
p.FeatureID = sqldb.SQLInt64(featureID)
})
if err != nil {
return nil, err
}
err = tx.InsertKVStoreRecord(ctx, p)
if err != nil {
return nil, err
}
return &sqlKvEntry{
kvEntry: entry,
ruleID: p.RuleID,
groupID: p.GroupID,
featureID: p.FeatureID,
}, nil
}
// getSQLValue retrieves the key value for the given kvEntry from the SQL
// database.
func getSQLValue(ctx context.Context, tx *sqlcmig6.Queries,
entry *sqlKvEntry) ([]byte, error) {
switch {
case entry.featureID.Valid && entry.groupID.Valid:
return tx.GetFeatureKVStoreRecord(
ctx, sqlcmig6.GetFeatureKVStoreRecordParams{
Perm: entry.perm,
RuleID: entry.ruleID,
GroupID: entry.groupID,
FeatureID: entry.featureID,
Key: entry.key,
},
)
case entry.groupID.Valid:
return tx.GetGroupKVStoreRecord(
ctx, sqlcmig6.GetGroupKVStoreRecordParams{
Perm: entry.perm,
RuleID: entry.ruleID,
GroupID: entry.groupID,
Key: entry.key,
},
)
case !entry.featureID.Valid && !entry.groupID.Valid:
return tx.GetGlobalKVStoreRecord(
ctx, sqlcmig6.GetGlobalKVStoreRecordParams{
Perm: entry.perm,
RuleID: entry.ruleID,
Key: entry.key,
},
)
default:
return nil, fmt.Errorf("invalid combination of feature and "+
"session ID: featureID valid: %v, groupID valid: %v",
entry.featureID.Valid, entry.groupID.Valid)
}
}
// verifyBktKeys checks that the given bucket only contains buckets with the
// passed keys, and optionally also key-value pairs. If the errorOnKeyValues
// parameter is set to true, the function will error if it finds key-value pairs
// in the bucket.
func verifyBktKeys(bkt *bbolt.Bucket, errorOnKeyValues bool,
keys ...[]byte) error {
return bkt.ForEach(func(key, v []byte) error {
if v != nil {
// If we allow key-values, then we can just continue
// to the next key. Else we need to error out, as we
// only expect buckets under the passed bucket.
if errorOnKeyValues {
return fmt.Errorf("unexpected key-value pair "+
"found: key=%s, value=%x", key, v)
}
return nil
}
for _, expectedKey := range keys {
if bytes.Equal(key, expectedKey) {
// If this is an expected key, we can continue
// to the next key.
return nil
}
}
return fmt.Errorf("unexpected key found: %s", key)
})
}
// migratePrivacyMapperDBToSQL runs the migration of the privacy mapper store
// from the KV database to the SQL database. The function also asserts that the
// migrated values match the original values in the privacy mapper store.
func migratePrivacyMapperDBToSQL(ctx context.Context, kvStore *bbolt.DB,
sqlTx *sqlcmig6.Queries, sessMap map[[4]byte]sqlcmig6.Session) error {
log.Infof("Starting migration of the privacy mapper store to SQL")
// 1) Collect all privacy pairs from the KV store.
privPairs, err := collectPrivacyPairs(kvStore, sessMap)
if err != nil {
return fmt.Errorf("error migrating privacy mapper store: %w",
err)
}
// 2) Insert all collected privacy pairs into the SQL database.
err = insertPrivacyPairs(ctx, sqlTx, privPairs)
if err != nil {
return fmt.Errorf("insertion of privacy pairs failed: %w", err)
}
// 3) Validate that all inserted privacy pairs match the original values
// in the KV store. Note that this is done after all values have been
// inserted, to ensure that the migration doesn't overwrite any values
// after they were inserted.
err = validatePrivacyPairsMigration(ctx, sqlTx, privPairs)
if err != nil {
return fmt.Errorf("migration validation of privacy pairs "+
"failed: %w", err)
}
log.Infof("Migration of the privacy mapper stores to SQL completed. "+
"Total number of rows migrated: %d", len(privPairs))
return nil
}
// collectPrivacyPairs collects all privacy pairs from the KV store.
func collectPrivacyPairs(kvStore *bbolt.DB,
sessMap map[[4]byte]sqlcmig6.Session) (privacyPairs, error) {
groupPairs := make(privacyPairs)
return groupPairs, kvStore.View(func(kvTx *bbolt.Tx) error {
bkt := kvTx.Bucket(privacyBucketKey)
if bkt == nil {
// If we haven't generated any privacy bucket yet,
// we can skip the migration, as there are no privacy
// pairs to migrate.
return nil
}
return bkt.ForEach(func(groupId, v []byte) error {
if v != nil {
return fmt.Errorf("expected only buckets "+
"under %s bkt, but found value %s",
privacyBucketKey, v)
}
gBkt := bkt.Bucket(groupId)
if gBkt == nil {
return fmt.Errorf("group bkt for group id "+
"%s not found", groupId)
}
var groupAlias [4]byte
copy(groupAlias[:], groupId)
sess, ok := sessMap[groupAlias]
if !ok {
// If we can't find the session group in the SQL
// db, that indicates that the session was never
// migrated from KVDB. This likely means that
// the user deleted their session.db file, but
// kept the rules.db file. As the privacy pairs
// are useless when the session no longer
// exists, we can just skip the migration of the
// privacy pairs for this group.
log.Warnf("Skipping migration of privacy "+
"pairs for session group %x, as the "+
"session group was not found", groupId)
return nil
}
if !sess.GroupID.Valid {
return fmt.Errorf("session group id for "+
"session %d is not set ", sess.ID)
}
groupRealToPseudoPairs, err := collectGroupPairs(gBkt)
if err != nil {
return fmt.Errorf("processing group bkt "+
"for group id %s (sqlID %d) failed: %w",
groupId, sess.GroupID.Int64, err)
}
groupPairs[sess.GroupID.Int64] = groupRealToPseudoPairs
return nil
})
})
}
// collectGroupPairs collects all privacy pairs for a specific session group,
// i.e. the group buckets under the privacy mapper bucket in the KV store.
// The function returns them as a map, where the key is the real value, and
// the value for the key is the pseudo values.
// It also checks that the pairs are consistent, i.e. that for each real value
// there is a corresponding pseudo value, and vice versa. If the pairs are
// inconsistent, it returns an error indicating the mismatch.
func collectGroupPairs(bkt *bbolt.Bucket) (map[string]string, error) {
var (
realToPseudoRes map[string]string
pseudoToRealRes map[string]string
err error
)
if realBkt := bkt.Bucket(realToPseudoKey); realBkt != nil {
realToPseudoRes, err = collectPairs(realBkt)
if err != nil {
return nil, fmt.Errorf("fetching real to pseudo pairs "+
"failed: %w", err)
}
} else {
return nil, fmt.Errorf("%s bucket not found", realToPseudoKey)
}
if pseudoBkt := bkt.Bucket(pseudoToRealKey); pseudoBkt != nil {
pseudoToRealRes, err = collectPairs(pseudoBkt)
if err != nil {
return nil, fmt.Errorf("fetching pseudo to real pairs "+
"failed: %w", err)
}
} else {
return nil, fmt.Errorf("%s bucket not found", pseudoToRealKey)
}
if len(realToPseudoRes) != len(pseudoToRealRes) {
return nil, fmt.Errorf("missmatch between nubmer of pairs in "+
"%s bucket (pairs found: %d) and %s bucket (pairs "+
"found: %d)", realToPseudoKey, len(realToPseudoRes),
pseudoToRealKey, len(pseudoToRealRes))
}
for realVal, pseudoVal := range realToPseudoRes {
if rv, ok := pseudoToRealRes[pseudoVal]; !ok || rv != realVal {
return nil, fmt.Errorf("the real value %s found in "+
"the %s bucket doesn't match the value %s "+
"found in the %s bucket",
realVal, realToPseudoKey, rv, pseudoToRealKey)
}
}
return realToPseudoRes, nil
}
// collectPairs collects all privacy pairs from a specific realToPseudoKey or
// pseudoToRealKey bucket in the KV store. It returns a map where the key is
// the real value or pseudo value, and the value is the corresponding pseudo
// value or real value, respectively (depending on if the realToPseudo or
// pseudoToReal bucket is passed to the function).
func collectPairs(pairsBucket *bbolt.Bucket) (map[string]string, error) {
pairsRes := make(map[string]string)
return pairsRes, pairsBucket.ForEach(func(k, v []byte) error {
if v == nil {
return fmt.Errorf("expected only key-values under "+
"pairs bucket, but found bucket %s", k)
}
if len(v) == 0 {
return fmt.Errorf("empty value stored for privacy "+
"pairs key %s", k)
}
pairsRes[string(k)] = string(v)
return nil
})
}
// insertPrivacyPairs inserts the collected privacy pairs into the SQL database.
func insertPrivacyPairs(ctx context.Context, sqlTx *sqlcmig6.Queries,
pairs privacyPairs) error {
for groupId, groupPairs := range pairs {
err := insertGroupPairs(ctx, sqlTx, groupId, groupPairs)
if err != nil {
return fmt.Errorf("inserting group pairs for group "+
"id %d failed: %w", groupId, err)
}
}
return nil
}
// insertGroupPairs inserts the privacy pairs for a specific group into
// the SQL database. It checks for duplicates before inserting, and returns
// an error if a duplicate pair is found. The function takes a map of real
// to pseudo values, where the key is the real value and the value is the
// corresponding pseudo value.
func insertGroupPairs(ctx context.Context, sqlTx *sqlcmig6.Queries,
groupID int64, pairs map[string]string) error {
for realVal, pseudoVal := range pairs {
err := sqlTx.InsertPrivacyPair(
ctx, sqlcmig6.InsertPrivacyPairParams{
GroupID: groupID,
RealVal: realVal,
PseudoVal: pseudoVal,
},
)
if err != nil {
return fmt.Errorf("inserting privacy pair %s:%s "+
"failed: %w", realVal, pseudoVal, err)
}
}
return nil
}
// validatePrivacyPairsMigration validates that the migrated privacy pairs
// match the original values in the KV store.
func validatePrivacyPairsMigration(ctx context.Context, sqlTx *sqlcmig6.Queries,
pairs privacyPairs) error {
for groupId, groupPairs := range pairs {
err := validateGroupPairsMigration(
ctx, sqlTx, groupId, groupPairs,
)
if err != nil {
return fmt.Errorf("migration validation of privacy "+
"pairs for group %d failed: %w", groupId, err)
}
}
return nil
}
// validateGroupPairsMigration validates that the migrated privacy pairs for
// a specific group match the original values in the KV store. It checks that
// for each real value, the pseudo value in the SQL database matches the
// original pseudo value, and vice versa. If any mismatch is found, it returns
// an error indicating the mismatch.
func validateGroupPairsMigration(ctx context.Context, sqlTx *sqlcmig6.Queries,
groupID int64, pairs map[string]string) error {
for realVal, pseudoVal := range pairs {
resPseudoVal, err := sqlTx.GetPseudoForReal(
ctx, sqlcmig6.GetPseudoForRealParams{
GroupID: groupID,
RealVal: realVal,
},
)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("migrated privacy pair %s:%s not "+
"found for real value", realVal, pseudoVal)
}
if err != nil {
return err
}
if resPseudoVal != pseudoVal {
return fmt.Errorf("pseudo value in db %s, does not "+
"match original value %s, for real value %s",
resPseudoVal, pseudoVal, realVal)
}
resRealVal, err := sqlTx.GetRealForPseudo(
ctx, sqlcmig6.GetRealForPseudoParams{
GroupID: groupID,
PseudoVal: pseudoVal,
},
)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("migrated privacy pair %s:%s not "+
"found for pseudo value", realVal, pseudoVal)
}
if err != nil {
return err
}
if resRealVal != realVal {
return fmt.Errorf("real value in db %s, does not "+
"match original value %s, for pseudo value %s",
resRealVal, realVal, pseudoVal)
}
}
return nil
}
// migrateActionsToSQL runs the migration of the actions store from the KV
// database to the SQL database. The function also asserts that the migrated
// values match the original values in the actions store.
func migrateActionsToSQL(ctx context.Context, kvStore *bbolt.DB,
sqlTx *sqlcmig6.Queries, macRootKeyIDs [][]byte,
sessMap map[[4]byte]sqlcmig6.Session) error {
log.Infof("Starting migration of the actions store to SQL")
// Start by fetching all accounts and sessions, and map them by their
// IDs. This will allow us to quickly look up any account(s) and/or
// session that match a specific action's macaroon identifier.
accts, err := sqlTx.ListAllAccounts(ctx)
if err != nil {
return fmt.Errorf("listing accounts failed: %w", err)
}
acctsMap, err := mapAccounts(accts)
if err != nil {
return fmt.Errorf("mapping accounts failed: %w", err)
}
// Next, as the kvdb actions only have their last 4 bytes set for the
// MacaroonRootKeyID field, we'll do a best effort attempt fetch the
// full root key ID (all 8 bytes) from lnd when migrating each action.
// We do so by mapping the macaroon root key IDs by their 4 byte suffix,
// to make it easy to look up the full root key ID for each action when
// we migrate them, as they only have the last 4 bytes set.
macMap, err := mapMacIds(macRootKeyIDs)
if err != nil {
return fmt.Errorf("mapping macaroon root key IDs failed: %w",
err)
}
// Iterate over and migrate all actions in the KVDB. Note that this
// function migrates each action while iterating over them, instead
// of first collecting all actions and storing them in memory before
// migrating them (which is common for other migrations). This is
// because in comparison to other stores, the actions store may contain
// a large number of entries.
err = kvStore.View(func(tx *bbolt.Tx) error {
actionsBucket := tx.Bucket(actionsBucketKey)
if actionsBucket == nil {
return fmt.Errorf("actions bucket not found")
}
sessionsBucket := actionsBucket.Bucket(actionsKey)
if sessionsBucket == nil {
return fmt.Errorf("actions->sessions bucket not found")
}
// Iterate over session ID buckets (i.e. what we should name
// macaroon IDs).
//
// nolint:ll
return sessionsBucket.ForEach(func(macID []byte, v []byte) error {
if v != nil {
return fmt.Errorf("expected only sub-buckets " +
"in sessions bucket")
}
sessBucket := sessionsBucket.Bucket(macID)
if sessBucket == nil {
return fmt.Errorf("session bucket for %x not "+
"found", macID)
}
// fetch the full macaroon root key ID based on the
// macaroon identifier for the action (the last 4 bytes
// of the root key ID).
var macIDArr [4]byte
copy(macIDArr[:], macID)
macRootKeyID, ok := macMap[macIDArr]
if !ok {
// If we don't have a mapping for this macaroon
// ID, this could mean that the user has deleted
// the lnd macaroon db, but not the litd
// firewalldb.
// As there is no way to recover the full
// macaroonRootKeyID at this point, we set the
// first 4 bytes to zeroes, similar to how the
// action is already persisted for kvdb
// backends.
log.Warnf("No macaroon root key ID found for "+
"macaroon ID %x, using zeroes for "+
"the first 4 bytes", macID)
macRootKeyID = make([]byte, 8)
copy(macRootKeyID[4:], macIDArr[:])
}
// Iterate over the actions inside each session/macaroon
// ID.
return sessBucket.ForEach(func(actionID,
actionBytes []byte) error {
if actionBytes == nil {
return fmt.Errorf("unexpected nested "+
"bucket under session %x",
macID)
}
sessionID, err := session.IDFromBytes(macID)
if err != nil {
// This should be unreachable, as the
// macID should always be 4 bytes long.
return fmt.Errorf("invalid session ID "+
"format %x: %v", macID, err)
}
action, err := DeserializeAction(
bytes.NewReader(actionBytes), sessionID,
)
if err != nil {
return fmt.Errorf("unable to "+
"deserialize action in "+
"session %x: %w", macID, err)
}
log.Infof("Migrated Action: Macaroon ID: %x, "+
"ActionID: %x, Actor: %s, Feature: %s",
macID, actionID, action.ActorName,
action.FeatureName)
// Now proceed to migrate the action, and also
// validate that the action was correctly
// migrated.
err = migrateActionToSQL(
ctx, sqlTx, acctsMap, sessMap, action,
macRootKeyID,
)
if err != nil {
return fmt.Errorf("migrating action "+
"to SQL failed: %w", err)
}
return nil
})
})
})
if err != nil {
return fmt.Errorf("iterating over actions failed: %w", err)
}
log.Infof("Finished iterating actions in KV store " +
"(no persistence yet).")
return nil
}
// migrateActionToSQL migrates a single action to the SQL database, and
// validates that the action was correctly migrated.
func migrateActionToSQL(ctx context.Context, sqlTx *sqlcmig6.Queries,
acctsMap map[[4]byte][]sqlcmig6.Account,
sessMap map[[4]byte]sqlcmig6.Session, action *Action,
macRootKeyID []byte) error {
var (
macIDSuffix [4]byte
err error
insertParams sqlcmig6.InsertActionParams
)
// Extract the last 4 bytes of the macaroon root key ID suffix, to find
// any potential linked account(s) and/or session for the action.
// Note that the macRootKeyID is guaranteed to be 8 bytes long.
copy(macIDSuffix[:], macRootKeyID[len(macRootKeyID)-4:])
actAccounts, hasAccounts := acctsMap[macIDSuffix]
actSession, hasSessions := sessMap[macIDSuffix]
// Based on if we found any potential linked account(s) and/or
// session, link the action to them in the SQL DB.
// The logic is as follows:
// 1) If we only find a potential linked session, the action
// is linked to the session.
// 2) If we only find potential linked account(s), the action
// is linked the account with the earliest expiry (where accounts
// that do not expire is seen as the earliest).
// 3) If we find both potential linked account(s) and session,
// the session is prioritized, and the action is linked
// to the session.
// 4) If we don't find any potential linked account(s) or session,
// the action is not linked to any account or session.
switch {
case hasAccounts && hasSessions:
// Alternative (3) above.
insertParams, err = paramsFromBothSessionAndAccounts(
ctx, sqlTx, action, actAccounts, actSession,
macRootKeyID,
)
case hasSessions:
// Alternative (1) above.
insertParams, err = paramsFromSession(
action, actSession, macRootKeyID,
)
case hasAccounts:
// Alternative (2) above.
insertParams, err = paramsFromAccounts(
ctx, sqlTx, action, actAccounts, macRootKeyID)
default:
// Alternative (4) above.
insertParams = paramsFromAction(action, macRootKeyID)
}
if err != nil {
return fmt.Errorf("getting insert params failed: %w", err)
}
// With the insert params ready, we can now insert the action
// into the SQL DB.
migratedActionID, err := sqlTx.InsertAction(ctx, insertParams)
if err != nil {
return fmt.Errorf(
"inserting action into SQL DB failed: %w", err,
)
}
// Finally, validate that the action was correctly migrated.
return validateMigratedAction(
ctx, sqlTx, action, insertParams, migratedActionID,
)
}
// validateMigratedAction validates that the migrated action in the SQL DB
// matches the original action in the KV DB. The function takes the original
// action, the insert params used to insert the action into the SQL DB,
// and the ID of the migrated action in the SQL DB.
func validateMigratedAction(ctx context.Context, sqlTx *sqlcmig6.Queries,
kvAction *Action, insertParams sqlcmig6.InsertActionParams,
migratedActionID int64) error {
// First, fetch the action back from the SQL DB.
migAction, err := getAndMarshalAction(ctx, sqlTx, migratedActionID)
if err != nil {
return fmt.Errorf("fetching migrated action with id %d from "+
"SQL DB failed: %w", migratedActionID, err)
}
// Before we compare the two actions, we need to override the
// time zone in the action.
overrideActionTimeZone(kvAction)
overrideActionTimeZone(migAction)
var (
overriddenSessID = fn.None[session.ID]()
overriddenAcctID = fn.None[accounts.AccountID]()
)
// As the original KVDB action does not persist session and account
// references correctly, we need to override them to the expected
// session and account IDs based on what the inserted SQL action's
// fields were set to. This is required in order to make the KVDB and
// SQL actions comparable.
if insertParams.SessionID.Valid {
sess, err := sqlTx.GetSessionByID(
ctx, insertParams.SessionID.Int64,
)
if err != nil {
return fmt.Errorf("unable to get session with id %d: "+
"%w", insertParams.SessionID.Int64, err)
}
overriddenSessID = fn.Some(session.ID(sess.Alias))
}
if insertParams.AccountID.Valid {
acct, err := sqlTx.GetAccount(
ctx, insertParams.AccountID.Int64,
)
if err != nil {
return fmt.Errorf("unable to get account with id %d: "+
"%w", insertParams.AccountID.Int64, err)
}
acctAlias, err := accounts.AccountIDFromInt64(acct.Alias)
if err != nil {
return fmt.Errorf("unable to get convert int64 "+
"account alias to []byte form: %w", err)
}
overriddenAcctID = fn.Some(acctAlias)
}
overrideActionSessionAndAccount(
kvAction, overriddenSessID, overriddenAcctID,
)
// Finally, we need to override the macaroon ID field in the migrated
// SQL action, as the KVDB action only has the last 4 bytes set, while
// the SQL action has the full 8 bytes set.
overrideMacRootKeyID(migAction)
// If there are no RPCParamsJson set for the actions, that's represented
// differently in KVDB vs SQL. We therefore override the RPCParamsJson
// so that both actions represent them in the same way.
overrideRPCParamsJson(kvAction, migAction)
// Now that we have overridden the fields that are expected to differ
// between the original KVDB action and the migrated SQL action, we can
// compare the two actions to ensure that they match.
if !reflect.DeepEqual(kvAction, migAction) {
diff := difflib.UnifiedDiff{
A: difflib.SplitLines(
spew.Sdump(kvAction),
),
B: difflib.SplitLines(
spew.Sdump(migAction),
),
FromFile: "Expected",
FromDate: "",
ToFile: "Actual",
ToDate: "",
Context: 3,
}
diffText, _ := difflib.GetUnifiedDiffString(diff)
return fmt.Errorf("migrated action does not match original "+
"action: \n%v", diffText)
}
return nil
}
// paramsFromBothSessionAndAccounts handles cases where both potential
// account(s) and session responsible for the action exists. In this case,
// we prioritize linking the action to the session. If the potential linked
// session is not a match for the action, we fall back to linking the action
// to the potential linked account with the earliest expiry (where accounts
// that do not expire is seen as the earliest).
func paramsFromBothSessionAndAccounts(ctx context.Context,
sqlTx *sqlcmig6.Queries, action *Action,
actAccts []sqlcmig6.Account, sess sqlcmig6.Session,
macRootKeyID []byte) (sqlcmig6.InsertActionParams, error) {
// Check if the potential linked session and account(s) could actually
// be responsible for the action, or if they should be filtered out.
sessOpt := getMatchingSessionForAction(action, sess)
acctOpt, err := getMatchingAccountForAction(
ctx, sqlTx, action, actAccts,
)
if err != nil {
return sqlcmig6.InsertActionParams{}, err
}
switch {
case acctOpt.IsSome() && sessOpt.IsSome():
// If we find both a potential linked account and session, we
// prio linking the session to the action.
return paramsFromSession(action, sess, macRootKeyID)
case acctOpt.IsSome():
// If the session was filtered out, but we still have an
// account, we link the action to the account.
return paramsFromAccounts(
ctx, sqlTx, action, actAccts, macRootKeyID,
)
case sessOpt.IsSome():
return paramsFromSession(action, sess, macRootKeyID)
default:
// If no potential linked account or session were found after
// filtering, we won't link the action to any of them.
return paramsFromAction(action, macRootKeyID), nil
}
}
// paramsFromSession returns the insert params for an action linked to a
// session. If the session is not a match for the action, the action will not be
// linked to the session.
func paramsFromSession(action *Action, actSess sqlcmig6.Session,
macRootKeyID []byte) (sqlcmig6.InsertActionParams, error) {
sessOpt := getMatchingSessionForAction(action, actSess)
params := paramsFromAction(action, macRootKeyID)
sessOpt.WhenSome(func(sess sqlcmig6.Session) {
params.SessionID = sqldb.SQLInt64(sess.ID)
params.AccountID = sess.AccountID
})
return params, nil
}
// paramsFromAccounts returns the insert params for an action linked to an
// account. If no matching account is found for the action, the action will not
// be linked to any account.
func paramsFromAccounts(ctx context.Context, sqlTx *sqlcmig6.Queries,
action *Action, actAccts []sqlcmig6.Account,
macRootKeyID []byte) (sqlcmig6.InsertActionParams, error) {
acctOpt, err := getMatchingAccountForAction(
ctx, sqlTx, action, actAccts,
)
if err != nil {
return sqlcmig6.InsertActionParams{}, err
}
params := paramsFromAction(action, macRootKeyID)
acctOpt.WhenSome(func(acct sqlcmig6.Account) {
params.AccountID = sqldb.SQLInt64(acct.ID)
})
return params, nil
}
// paramsFromAction returns the insert params for an action that is not linked
// to any account or session.
func paramsFromAction(action *Action,
macRootKeyID []byte) sqlcmig6.InsertActionParams {
return sqlcmig6.InsertActionParams{
MacaroonIdentifier: macRootKeyID,
ActorName: sqldb.SQLStr(action.ActorName),
FeatureName: sqldb.SQLStr(action.FeatureName),
ActionTrigger: sqldb.SQLStr(action.Trigger),
Intent: sqldb.SQLStr(action.Intent),
StructuredJsonData: []byte(action.StructuredJsonData),
RpcMethod: action.RPCMethod,
RpcParamsJson: action.RPCParamsJson,
CreatedAt: action.AttemptedAt,
ActionState: int16(action.State),
ErrorReason: sqldb.SQLStr(action.ErrorReason),
}
}
// getMatchingSessionForAction checks if the potential linked session
// could actually be responsible for the action, or if it should be filtered
// out.
func getMatchingSessionForAction(action *Action,
sess sqlcmig6.Session) fn.Option[sqlcmig6.Session] {
attempted := action.AttemptedAt
// We filter of the session if the session could not have been
// responsible for the action, based on the action's attempted
// timestamp.
// Exclude the session if it was revoked before the attempted at time.
if sess.RevokedAt.Valid && sess.RevokedAt.Time.Before(attempted) {
return fn.None[sqlcmig6.Session]()
}
// Exclude the session if it was created after the attempt at time.
if sess.CreatedAt.After(attempted) {
return fn.None[sqlcmig6.Session]()
}
// Exclude the session if it expired before the attempt at time.
if sess.Expiry.Before(attempted) {
return fn.None[sqlcmig6.Session]()
}
// If we reach this point, the session is a potential match for
// the action.
return fn.Some(sess)
}
// getMatchingAccountForAction checks if any of the potential linked account(s)
// could actually be responsible for the action, or if they should be
// filtered out. If multiple accounts remain after filtering, we pick the one
// with the earliest expiration, but where non expiring accounts are picked
// first. The reason for picking the earliest expiration is motivated with the
// reasoning that such accounts were more likely to have existed at the time of
// the action, as we have no way of tracking when the account was created.
func getMatchingAccountForAction(ctx context.Context,
sqlTx *sqlcmig6.Queries, action *Action,
actAccts []sqlcmig6.Account) (fn.Option[sqlcmig6.Account], error) {
// sendMethods is the RPC methods that trigger payments to be added an
// account. We use this to filter out accounts that have no payments
// when the action is triggered by sending a payment.
var sendMethods = map[string]struct{}{
"/lnrpc.Lightning/SendPayment": {},
"/lnrpc.Lightning/SendPaymentSync": {},
"/routerrpc.Router/SendPaymentV2": {},
"/lnrpc.Lightning/SendToRoute": {},
"/lnrpc.Lightning/SendToRouteSync": {},
"/routerrpc.Router/SendToRouteV2": {},
}
// We cannot have an ActorName set for an action if the action was
// triggered by an account.
if action.ActorName != "" {
return fn.None[sqlcmig6.Account](), nil
}
attempted := action.AttemptedAt
// 1) Do some initial filtering of the accounts.
filtered := make([]sqlcmig6.Account, 0, len(actAccts))
for _, a := range actAccts {
// Exclude the account if it expired before the attempt at time.
if !a.Expiration.IsZero() && a.Expiration.Before(attempted) {
continue
}
invoices, err := sqlTx.ListAccountInvoices(ctx, a.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fn.None[sqlcmig6.Account](), fmt.Errorf(
"listing invoices for account %d failed: %w",
a.ID, err)
}
payments, err := sqlTx.ListAccountPayments(ctx, a.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fn.None[sqlcmig6.Account](), fmt.Errorf(
"listing payments for account %d failed: %w",
a.ID, err)
}
// Exclude the account if the action is triggered by creating
// an invoice, but the account has no invoices.
if action.RPCMethod == "/lnrpc.Lightning/AddInvoice" {
if len(invoices) == 0 {
continue
}
}
// Exclude the account if the action is triggered by sending
// a payment, but the account has no payments.
if _, ok := sendMethods[action.RPCMethod]; ok {
if len(payments) == 0 {
continue
}
}
filtered = append(filtered, a)
}
// 2) If no accounts remain after filtering, no potential linked account
// for the action was found.
if len(filtered) == 0 {
return fn.None[sqlcmig6.Account](), nil
}
// 3) If multiple accounts remain after filtering, we pick the one with
// the earliest expiration, but where non expiring accounts are
// picked first.
if len(filtered) > 1 {
sort.Slice(filtered, func(i, j int) bool {
zeroI := filtered[i].Expiration.IsZero()
zeroJ := filtered[j].Expiration.IsZero()
// If one is zero and the other is not, zero comes first
if zeroI && !zeroJ {
return true
}
if zeroJ && !zeroI {
return false
}
// Else, both are zero or both are non-zero. If both are
// non-zero, we pick the earliest expiration first.
return filtered[i].Expiration.Before(
filtered[j].Expiration,
)
})
}
// 4) Return the first account of the filtered list, which has been
// ordered if multiple accounts remain.
return fn.Some(filtered[0]), nil
}
// getAndMarshalAction fetches an action by its ID from the SQL DB, and marshals
// it into the Action struct.
func getAndMarshalAction(ctx context.Context, sqlTx *sqlcmig6.Queries,
id int64) (*Action, error) {
// First, fetch the action back from the SQL DB.
dbAction, err := sqlTx.GetAction(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("action not found")
} else if err != nil {
return nil, err
}
return marshalDBAction(ctx, sqlTx, dbAction)
}
// marshalDBAction marshals a sqlcmig6.Action into the Action struct.
func marshalDBAction(ctx context.Context, sqlTx *sqlcmig6.Queries,
dbAction sqlcmig6.Action) (*Action, error) {
var legacySessID fn.Option[session.ID]
if dbAction.SessionID.Valid {
legacySessIDB, err := sqlTx.GetAliasBySessionID(
ctx, dbAction.SessionID.Int64,
)
if err != nil {
return nil, fmt.Errorf("unable to get legacy "+
"session ID for session ID %d: %w",
dbAction.SessionID.Int64, err)
}
sessID, err := session.IDFromBytes(legacySessIDB)
if err != nil {
return nil, err
}
legacySessID = fn.Some(sessID)
}
var legacyAcctID fn.Option[accounts.AccountID]
if dbAction.AccountID.Valid {
acct, err := sqlTx.GetAccount(ctx, dbAction.AccountID.Int64)
if err != nil {
return nil, err
}
acctID, err := accounts.AccountIDFromInt64(acct.Alias)
if err != nil {
return nil, fmt.Errorf("unable to get account ID: %w",
err)
}
legacyAcctID = fn.Some(acctID)
}
// Note that we export the full 8 byte macaroon root key ID in the sql
// actions DB, while the kvdb version persists and exports stored the
// last 4 bytes only.
var macRootKeyID fn.Option[uint64]
if len(dbAction.MacaroonIdentifier) >= 8 {
macRootKeyID = fn.Some(
binary.BigEndian.Uint64(dbAction.MacaroonIdentifier),
)
}
return &Action{
AddActionReq: AddActionReq{
MacaroonRootKeyID: macRootKeyID,
AccountID: legacyAcctID,
SessionID: legacySessID,
ActorName: dbAction.ActorName.String,
FeatureName: dbAction.FeatureName.String,
Trigger: dbAction.ActionTrigger.String,
Intent: dbAction.Intent.String,
StructuredJsonData: string(dbAction.StructuredJsonData),
RPCMethod: dbAction.RpcMethod,
RPCParamsJson: dbAction.RpcParamsJson,
},
AttemptedAt: dbAction.CreatedAt,
State: ActionState(dbAction.ActionState),
ErrorReason: dbAction.ErrorReason.String,
}, nil
}
// mapMacIds maps the macaroon root key IDs by their 4 byte suffix to make it
// easy to look up the full root key ID for each action based on the macaroon
// identifier (which is the last 4 bytes of the root key ID).
// The function returns a map where the key is the 4 byte suffix, and the
// value is the full root key ID.
func mapMacIds(macRootKeyIDs [][]byte) (map[[4]byte][]byte, error) {
// Start by converting the macRootKeyIDs to a map that let's us map the
// macaroon the 4 byte identifiers to the full uint64 RootKeyID.
macMap := make(map[[4]byte][]byte)
for _, id := range macRootKeyIDs {
if len(id) < 4 {
return nil, fmt.Errorf("expected rootKeyID to be at "+
"least 4 bytes long, got %d bytes", len(id))
}
// Extract the last 4 bytes of the root key ID to use as the
// key in the map.
var rootKeyShortID [4]byte
copy(rootKeyShortID[:], id[len(id)-4:])
// NOTE: If we already have an entry for this rootKeyShortID,
// we overwrite it with the new RootKeyID, as we can't determine
// which one is the correct one.
macMap[rootKeyShortID] = id
}
return macMap, nil
}
// mapAccounts maps the accounts by the 4 byte prefix of their Alias to make
// it easy to look up any potential linked account(s) for each action based
// on the macaroon identifier (which is the last 4 bytes of the root key ID).
// The function returns a map where the key is the 4 byte account prefix, and
// the value is a list of accounts that match that prefix.
func mapAccounts(accts []sqlcmig6.Account) (map[[4]byte][]sqlcmig6.Account,
error) {
acctMap := make(map[[4]byte][]sqlcmig6.Account)
for _, acct := range accts {
aliasBytes := make([]byte, 8)
// Convert the int64 account Alias to bytes (big-endian).
binary.BigEndian.PutUint64(aliasBytes, uint64(acct.Alias))
var acctPrefix [4]byte
copy(acctPrefix[:], aliasBytes[:4])
if acctList, ok := acctMap[acctPrefix]; ok {
acctMap[acctPrefix] = append(acctList, acct)
} else {
acctMap[acctPrefix] = []sqlcmig6.Account{acct}
}
}
return acctMap, nil
}
// mapSessions maps the sessions by their 4 byte Alias, to make it easy to
// look up any potential linked session for each action based on the macaroon
// identifier (which is the last 4 bytes of the root key ID).
// The function returns a map where the key is the 4 byte Alias, and the
// value is the corresponding session.
func mapSessions(sessions []sqlcmig6.Session) (map[[4]byte]sqlcmig6.Session,
error) {
sessMap := make(map[[4]byte]sqlcmig6.Session)
for _, sess := range sessions {
if len(sess.Alias) != 4 {
return nil, fmt.Errorf("session alias must be 4 "+
"bytes, got %d bytes", len(sess.Alias))
}
var sessAlias [4]byte
copy(sessAlias[:], sess.Alias[:4])
if _, ok := sessMap[sessAlias]; ok {
// NOTE: This should be unreachable, as we shouldn't
// have multiple sessions with the same Alias, as the
// sessions store has already been migrated to SQL here,
// and the session's table has a UNIQUE constraint on
// the Alias column.
return nil, fmt.Errorf("shouldn't have multiple "+
"sessions with the same alias %x", sessAlias)
} else {
sessMap[sessAlias] = sess
}
}
return sessMap, nil
}
// overrideActionTimeZone overrides the time zone of the action to the local
// time zone and chops off the nanosecond part for comparison. This is needed
// because KV database stores times as-is which as an unwanted side effect would
// fail migration due to time comparison expecting both the original and
// migrated actions to be in the same local time zone and in microsecond
// precision. Note that PostgresSQL stores times in microsecond precision while
// SQLite can store times in nanosecond precision if using TEXT storage class.
func overrideActionTimeZone(action *Action) {
fixTime := func(t time.Time) time.Time {
return t.In(time.Local).Truncate(time.Microsecond)
}
if !action.AttemptedAt.IsZero() {
action.AttemptedAt = fixTime(action.AttemptedAt)
}
}
// overrideActionSessionAndAccount overrides the session and account IDs of the
// action to the provided values.
func overrideActionSessionAndAccount(action *Action,
sessID fn.Option[session.ID], acctID fn.Option[accounts.AccountID]) {
action.SessionID = sessID
action.AccountID = acctID
}
// overrideMacRootKeyID overrides the MacaroonRootKeyID of the action to only
// contain the last 4 bytes (least significant 32 bits) of the original value.
// The first 4 bytes are set to zeroes.
// This is needed because the KV database only persists the last 4 bytes of the
// root key ID, while the SQL database persists the full 8 bytes.
func overrideMacRootKeyID(action *Action) {
action.MacaroonRootKeyID.WhenSome(func(macID uint64) {
// Extract only the last 32 bits (least significant 4 bytes).
last32 := macID & 0xFFFFFFFF
action.MacaroonRootKeyID = fn.Some(last32)
})
}
// overrideRPCParamsJson overrides the SQL action's RPCParamsJson in case they
// are empty for the kvAction. In the SQL DB, empty RPCParamsJson are
// represented as nil, while they are represented as an empty array in the
// KVDB version. Therefore, this function overrides the SQL action's
// RPCParamsJson to an empty array if they are nil.
func overrideRPCParamsJson(kvAction *Action, sqlAction *Action) {
if len(kvAction.RPCParamsJson) == 0 && sqlAction.RPCParamsJson == nil {
sqlAction.RPCParamsJson = []byte{}
}
}