From 18692f6f9f4acef082cde655f10d8ba17fecc801 Mon Sep 17 00:00:00 2001 From: PJ Date: Tue, 20 Aug 2024 13:57:29 +0200 Subject: [PATCH 01/41] bus: add /rhp/form endpoint --- bus/bus.go | 34 +++- bus/client/rhp.go | 21 +++ bus/routes.go | 268 +++++++++++++++++++++++++---- cmd/renterd/node.go | 6 +- internal/{worker => rhp}/dialer.go | 2 +- internal/rhp/v2/rhp.go | 8 +- internal/test/e2e/cluster.go | 6 +- internal/test/e2e/rhp_test.go | 81 +++++++++ internal/utils/errors.go | 5 + worker/worker.go | 5 +- 10 files changed, 391 insertions(+), 45 deletions(-) create mode 100644 bus/client/rhp.go rename internal/{worker => rhp}/dialer.go (99%) create mode 100644 internal/test/e2e/rhp_test.go diff --git a/bus/bus.go b/bus/bus.go index ec6807705..3a2ad570a 100644 --- a/bus/bus.go +++ b/bus/bus.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "math/big" + "net" "net/http" "time" @@ -25,10 +26,13 @@ import ( "go.sia.tech/renterd/api" "go.sia.tech/renterd/bus/client" ibus "go.sia.tech/renterd/internal/bus" + "go.sia.tech/renterd/internal/rhp" + rhp2 "go.sia.tech/renterd/internal/rhp/v2" "go.sia.tech/renterd/object" "go.sia.tech/renterd/stores/sql" "go.sia.tech/renterd/webhooks" "go.uber.org/zap" + "golang.org/x/crypto/blake2b" ) const ( @@ -302,6 +306,7 @@ type ( type Bus struct { startTime time.Time + masterKey [32]byte accountsMgr AccountManager alerts alerts.Alerter @@ -319,6 +324,8 @@ type Bus struct { mtrcs MetricsStore ss SettingStore + rhp2 *rhp2.Client + contractLocker ContractLocker sectors UploadingSectorsCache walletMetricsRecorder WalletMetricsRecorder @@ -327,10 +334,13 @@ type Bus struct { } // New returns a new Bus -func New(ctx context.Context, am AlertManager, wm WebhooksManager, cm ChainManager, s Syncer, w Wallet, store Store, announcementMaxAge time.Duration, l *zap.Logger) (_ *Bus, err error) { +func New(ctx context.Context, masterKey [32]byte, am AlertManager, wm WebhooksManager, cm ChainManager, s Syncer, w Wallet, store Store, announcementMaxAge time.Duration, l *zap.Logger) (_ *Bus, err error) { l = l.Named("bus") b := &Bus{ + startTime: time.Now(), + masterKey: masterKey, + s: s, cm: cm, w: w, @@ -345,7 +355,7 @@ func New(ctx context.Context, am AlertManager, wm WebhooksManager, cm ChainManag webhooksMgr: wm, logger: l.Sugar(), - startTime: time.Now(), + rhp2: rhp2.New(rhp.NewFallbackDialer(store, net.Dialer{}, l), l), } // init settings @@ -468,6 +478,8 @@ func (b *Bus) Handler() http.Handler { "POST /slabbuffer/done": b.packedSlabsHandlerDonePOST, "POST /slabbuffer/fetch": b.packedSlabsHandlerFetchPOST, + "POST /rhp/form": b.rhpFormHandler, + "POST /search/hosts": b.searchHostsHandlerPOST, "GET /search/objects": b.searchObjectsHandlerGET, @@ -608,3 +620,21 @@ func (b *Bus) initSettings(ctx context.Context) error { return nil } + +func (b *Bus) deriveRenterKey(hostKey types.PublicKey) types.PrivateKey { + seed := blake2b.Sum256(append(b.deriveSubKey("renterkey"), hostKey[:]...)) + pk := types.NewPrivateKeyFromSeed(seed[:]) + for i := range seed { + seed[i] = 0 + } + return pk +} + +func (b *Bus) deriveSubKey(purpose string) types.PrivateKey { + seed := blake2b.Sum256(append(b.masterKey[:], []byte(purpose)...)) + pk := types.NewPrivateKeyFromSeed(seed[:]) + for i := range seed { + seed[i] = 0 + } + return pk +} diff --git a/bus/client/rhp.go b/bus/client/rhp.go new file mode 100644 index 000000000..52c82cd4c --- /dev/null +++ b/bus/client/rhp.go @@ -0,0 +1,21 @@ +package client + +import ( + "context" + + "go.sia.tech/core/types" + "go.sia.tech/renterd/api" +) + +// RHPForm forms a contract with a host and adds it to the bus. +func (c *Client) RHPForm(ctx context.Context, endHeight uint64, hostKey types.PublicKey, hostIP string, renterAddress types.Address, renterFunds types.Currency, hostCollateral types.Currency) (contractID types.FileContractID, err error) { + err = c.c.WithContext(ctx).POST("/rhp/form", api.RHPFormRequest{ + EndHeight: endHeight, + HostCollateral: hostCollateral, + HostKey: hostKey, + HostIP: hostIP, + RenterFunds: renterFunds, + RenterAddress: renterAddress, + }, &contractID) + return +} diff --git a/bus/routes.go b/bus/routes.go index 262e53a11..82272aeb6 100644 --- a/bus/routes.go +++ b/bus/routes.go @@ -17,6 +17,7 @@ import ( rhpv3 "go.sia.tech/core/rhp/v3" ibus "go.sia.tech/renterd/internal/bus" + "go.sia.tech/renterd/internal/gouging" "go.sia.tech/core/gateway" "go.sia.tech/core/types" @@ -354,54 +355,52 @@ func (b *Bus) walletSendSiacoinsHandler(jc jape.Context) { } } - state := b.cm.TipState() - // if the current height is below the v2 hardfork height, send a v1 - // transaction - if state.Index.Height < state.Network.HardforkV2.AllowHeight { - // build transaction - txn := types.Transaction{ - MinerFees: []types.Currency{minerFee}, + // send V2 transaction if we're passed the V2 hardfork allow height + if b.isPassedV2AllowHeight() { + txn := types.V2Transaction{ + MinerFee: minerFee, SiacoinOutputs: []types.SiacoinOutput{ {Address: req.Address, Value: req.Amount}, }, } - toSign, err := b.w.FundTransaction(&txn, req.Amount.Add(minerFee), req.UseUnconfirmed) + // fund and sign transaction + state, toSign, err := b.w.FundV2Transaction(&txn, req.Amount.Add(minerFee), req.UseUnconfirmed) if jc.Check("failed to fund transaction", err) != nil { return } - b.w.SignTransaction(&txn, toSign, types.CoveredFields{WholeTransaction: true}) - // shouldn't be necessary to get parents since the transaction is - // not using unconfirmed outputs, but good practice - txnset := append(b.cm.UnconfirmedParents(txn), txn) + b.w.SignV2Inputs(state, &txn, toSign) + txnset := append(b.cm.V2UnconfirmedParents(txn), txn) // verify the transaction and add it to the transaction pool - if _, err := b.cm.AddPoolTransactions(txnset); jc.Check("failed to add transaction set", err) != nil { - b.w.ReleaseInputs([]types.Transaction{txn}, nil) + if _, err := b.cm.AddV2PoolTransactions(state.Index, txnset); jc.Check("failed to add v2 transaction set", err) != nil { + b.w.ReleaseInputs(nil, []types.V2Transaction{txn}) return } // broadcast the transaction - b.s.BroadcastTransactionSet(txnset) + b.s.BroadcastV2TransactionSet(state.Index, txnset) jc.Encode(txn.ID()) } else { - txn := types.V2Transaction{ - MinerFee: minerFee, + // build transaction + txn := types.Transaction{ + MinerFees: []types.Currency{minerFee}, SiacoinOutputs: []types.SiacoinOutput{ {Address: req.Address, Value: req.Amount}, }, } - // fund and sign transaction - state, toSign, err := b.w.FundV2Transaction(&txn, req.Amount.Add(minerFee), req.UseUnconfirmed) + toSign, err := b.w.FundTransaction(&txn, req.Amount.Add(minerFee), req.UseUnconfirmed) if jc.Check("failed to fund transaction", err) != nil { return } - b.w.SignV2Inputs(state, &txn, toSign) - txnset := append(b.cm.V2UnconfirmedParents(txn), txn) + b.w.SignTransaction(&txn, toSign, types.CoveredFields{WholeTransaction: true}) + // shouldn't be necessary to get parents since the transaction is + // not using unconfirmed outputs, but good practice + txnset := append(b.cm.UnconfirmedParents(txn), txn) // verify the transaction and add it to the transaction pool - if _, err := b.cm.AddV2PoolTransactions(state.Index, txnset); jc.Check("failed to add v2 transaction set", err) != nil { - b.w.ReleaseInputs(nil, []types.V2Transaction{txn}) + if _, err := b.cm.AddPoolTransactions(txnset); jc.Check("failed to add transaction set", err) != nil { + b.w.ReleaseInputs([]types.Transaction{txn}, nil) return } // broadcast the transaction - b.s.BroadcastV2TransactionSet(state.Index, txnset) + b.s.BroadcastTransactionSet(txnset) jc.Encode(txn.ID()) } } @@ -470,22 +469,22 @@ func (b *Bus) walletPrepareFormHandler(jc jape.Context) { jc.Error(errors.New("no renter key provided"), http.StatusBadRequest) return } - cs := b.cm.TipState() - fc := rhpv2.PrepareContractFormation(wpfr.RenterKey, wpfr.HostKey, wpfr.RenterFunds, wpfr.HostCollateral, wpfr.EndHeight, wpfr.HostSettings, wpfr.RenterAddress) - cost := rhpv2.ContractFormationCost(cs, fc, wpfr.HostSettings.ContractPrice) - txn := types.Transaction{ - FileContracts: []types.FileContract{fc}, - } - txn.MinerFees = []types.Currency{b.cm.RecommendedFee().Mul64(cs.TransactionWeight(txn))} - toSign, err := b.w.FundTransaction(&txn, cost.Add(txn.MinerFees[0]), true) - if jc.Check("couldn't fund transaction", err) != nil { + if txns, _, err := b.prepareForm( + jc.Request.Context(), + wpfr.RenterAddress, + wpfr.RenterKey, + wpfr.RenterFunds, + wpfr.HostCollateral, + wpfr.HostKey, + wpfr.HostSettings, + wpfr.EndHeight, + ); err != nil { + jc.Error(err, http.StatusInternalServerError) return + } else { + jc.Encode(txns) } - - b.w.SignTransaction(&txn, toSign, wallet.ExplicitCoveredFields(txn)) - - jc.Encode(append(b.cm.UnconfirmedParents(txn), txn)) } func (b *Bus) walletPrepareRenewHandler(jc jape.Context) { @@ -2281,3 +2280,198 @@ func (b *Bus) multipartHandlerListPartsPOST(jc jape.Context) { } jc.Encode(resp) } + +func (b *Bus) rhpFormHandler(jc jape.Context) { + // apply pessimistic timeout + ctx, cancel := context.WithTimeout(jc.Request.Context(), 15*time.Minute) + defer cancel() + + // decode the request + var rfr api.RHPFormRequest + if jc.Decode(&rfr) != nil { + return + } + + // validate the request + if rfr.EndHeight == 0 { + http.Error(jc.ResponseWriter, "EndHeight can not be zero", http.StatusBadRequest) + return + } else if rfr.HostKey == (types.PublicKey{}) { + http.Error(jc.ResponseWriter, "HostKey must be provided", http.StatusBadRequest) + return + } else if rfr.HostCollateral.IsZero() { + http.Error(jc.ResponseWriter, "HostCollateral can not be zero", http.StatusBadRequest) + return + } else if rfr.HostIP == "" { + http.Error(jc.ResponseWriter, "HostIP must be provided", http.StatusBadRequest) + return + } else if rfr.RenterFunds.IsZero() { + http.Error(jc.ResponseWriter, "RenterFunds can not be zero", http.StatusBadRequest) + return + } else if rfr.RenterAddress == (types.Address{}) { + http.Error(jc.ResponseWriter, "RenterAddress must be provided", http.StatusBadRequest) + return + } + + // fetch gouging parameters + gp, err := b.gougingParams(ctx) + if jc.Check("could not get gouging parameters", err) != nil { + return + } + gc := gouging.NewChecker(gp.GougingSettings, gp.ConsensusState, gp.TransactionFee, nil, nil) + + // send V2 transaction if we're passed the V2 hardfork allow height + var contract rhpv2.ContractRevision + if b.isPassedV2AllowHeight() { + // form the contract + var txnSet []types.V2Transaction + contract, txnSet, err = b.rhp2.FormV2Contract( + ctx, + rfr.RenterAddress, + b.deriveRenterKey(rfr.HostKey), + rfr.HostKey, + rfr.HostIP, + rfr.RenterFunds, + rfr.HostCollateral, + rfr.EndHeight, + gc, + b.prepareFormV2, + ) + if errors.Is(err, utils.ErrNotImplemented) { + jc.Error(err, http.StatusNotImplemented) // TODO: remove once rhp4 is implemented + return + } else if jc.Check("couldn't form contract", err) != nil { + return + } + + // fetch state + state := b.cm.TipState() + + // add transaction set to the pool + _, err := b.cm.AddV2PoolTransactions(state.Index, txnSet) + if jc.Check("couldn't broadcast transaction set", err) != nil { + b.w.ReleaseInputs(nil, txnSet) + return + } + + // broadcast the transaction set + b.s.BroadcastV2TransactionSet(state.Index, txnSet) + } else { + // form the contract + var txnSet []types.Transaction + contract, txnSet, err = b.rhp2.FormContract( + ctx, + rfr.RenterAddress, + b.deriveRenterKey(rfr.HostKey), + rfr.HostKey, + rfr.HostIP, + rfr.RenterFunds, + rfr.HostCollateral, + rfr.EndHeight, + gc, + b.prepareForm, + ) + if jc.Check("couldn't form contract", err) != nil { + return + } + + // add transaction set to the pool + _, err := b.cm.AddPoolTransactions(txnSet) + if jc.Check("couldn't broadcast transaction set", err) != nil { + b.w.ReleaseInputs(txnSet, nil) + return + } + + // broadcast the transaction set + b.s.BroadcastTransactionSet(txnSet) + } + + // store the contract + _, err = b.ms.AddContract( + ctx, + contract, + contract.Revision.MissedHostPayout().Sub(rfr.HostCollateral), + rfr.RenterFunds, + b.cm.Tip().Height, + api.ContractStatePending, + ) + if jc.Check("couldn't store contract", err) != nil { + return + } + + // return the contract ID + jc.Encode(contract.ID()) +} + +func (b *Bus) prepareForm(ctx context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) ([]types.Transaction, func(types.Transaction), error) { + // prepare the transaction + cs := b.cm.TipState() + fc := rhpv2.PrepareContractFormation(renterKey, hostKey, renterFunds, hostCollateral, endHeight, hostSettings, renterAddress) + txn := types.Transaction{FileContracts: []types.FileContract{fc}} + + // calculate the miner fee + fee := b.cm.RecommendedFee().Mul64(cs.TransactionWeight(txn)) + txn.MinerFees = []types.Currency{fee} + + // fund the transaction + cost := rhpv2.ContractFormationCost(cs, fc, hostSettings.ContractPrice).Add(fee) + toSign, err := b.w.FundTransaction(&txn, cost, true) + if err != nil { + return nil, nil, fmt.Errorf("couldn't fund transaction: %w", err) + } + + // sign the transaction + b.w.SignTransaction(&txn, toSign, wallet.ExplicitCoveredFields(txn)) + + txns := append(b.cm.UnconfirmedParents(txn), txn) + return txns, func(txn types.Transaction) { b.w.ReleaseInputs(txns, nil) }, nil +} + +func (b *Bus) prepareFormV2(ctx context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) ([]types.V2Transaction, func(types.V2Transaction), error) { + hostFunds := hostSettings.ContractPrice.Add(hostCollateral) + + // prepare the transaction + cs := b.cm.TipState() + fc := types.V2FileContract{ + RevisionNumber: 0, + Filesize: 0, + FileMerkleRoot: types.Hash256{}, + ProofHeight: endHeight + hostSettings.WindowSize, + ExpirationHeight: endHeight + hostSettings.WindowSize + 10, + RenterOutput: types.SiacoinOutput{ + Value: renterFunds, + Address: renterAddress, + }, + HostOutput: types.SiacoinOutput{ + Value: hostFunds, + Address: hostSettings.Address, + }, + MissedHostValue: hostFunds, + TotalCollateral: hostFunds, + RenterPublicKey: renterKey, + HostPublicKey: hostKey, + } + txn := types.V2Transaction{FileContracts: []types.V2FileContract{fc}} + + // calculate the miner fee + fee := b.cm.RecommendedFee().Mul64(cs.V2TransactionWeight(txn)) + txn.MinerFee = fee + + // fund the transaction + fundAmount := cs.V2FileContractTax(fc).Add(hostFunds).Add(renterFunds).Add(fee) + cs, toSign, err := b.w.FundV2Transaction(&txn, fundAmount, false) + if err != nil { + return nil, nil, fmt.Errorf("couldn't fund transaction: %w", err) + } + + // sign the transaction + b.w.SignV2Inputs(cs, &txn, toSign) + + txns := append(b.cm.V2UnconfirmedParents(txn), txn) + return txns, func(txn types.V2Transaction) { b.w.ReleaseInputs(nil, txns) }, nil +} + +func (b *Bus) isPassedV2AllowHeight() bool { + cs := b.cm.TipState() + return cs.Index.Height >= cs.Network.HardforkV2.AllowHeight +} diff --git a/cmd/renterd/node.go b/cmd/renterd/node.go index b4c330fc4..b7131febb 100644 --- a/cmd/renterd/node.go +++ b/cmd/renterd/node.go @@ -375,9 +375,13 @@ func newBus(ctx context.Context, cfg config.Config, pk types.PrivateKey, network } } + // create master key - we currently derive the same key used by the workers + // to ensure contracts formed by the bus can be renewed by the autopilot + masterKey := blake2b.Sum256(append([]byte("worker"), pk...)) + // create bus announcementMaxAgeHours := time.Duration(cfg.Bus.AnnouncementMaxAgeHours) * time.Hour - b, err := bus.New(ctx, alertsMgr, wh, cm, s, w, sqlStore, announcementMaxAgeHours, logger) + b, err := bus.New(ctx, masterKey, alertsMgr, wh, cm, s, w, sqlStore, announcementMaxAgeHours, logger) if err != nil { return nil, nil, fmt.Errorf("failed to create bus: %w", err) } diff --git a/internal/worker/dialer.go b/internal/rhp/dialer.go similarity index 99% rename from internal/worker/dialer.go rename to internal/rhp/dialer.go index 56e51ce42..b2f87b32e 100644 --- a/internal/worker/dialer.go +++ b/internal/rhp/dialer.go @@ -1,4 +1,4 @@ -package worker +package rhp import ( "context" diff --git a/internal/rhp/v2/rhp.go b/internal/rhp/v2/rhp.go index 9786bf4c8..01b0a56cc 100644 --- a/internal/rhp/v2/rhp.go +++ b/internal/rhp/v2/rhp.go @@ -72,7 +72,8 @@ type ( Dial(ctx context.Context, hk types.PublicKey, address string) (net.Conn, error) } - PrepareFormFn func(ctx context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) (txns []types.Transaction, discard func(types.Transaction), err error) + PrepareFormFn func(ctx context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) (txns []types.Transaction, discard func(types.Transaction), err error) + PrepareV2FormFn func(ctx context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) (txns []types.V2Transaction, discard func(types.V2Transaction), err error) ) type Client struct { @@ -183,6 +184,11 @@ func (c *Client) FormContract(ctx context.Context, renterAddress types.Address, return } +func (c *Client) FormV2Contract(ctx context.Context, renterAddress types.Address, renterKey types.PrivateKey, hostKey types.PublicKey, hostIP string, renterFunds, hostCollateral types.Currency, endHeight uint64, gougingChecker gouging.Checker, prepareForm PrepareV2FormFn) (contract rhpv2.ContractRevision, txnSet []types.V2Transaction, err error) { + err = fmt.Errorf("%w; forming contracts using V2 transactions is not supported yet", utils.ErrNotImplemented) + return +} + func (c *Client) PruneContract(ctx context.Context, renterKey types.PrivateKey, gougingChecker gouging.Checker, hostIP string, hostKey types.PublicKey, fcid types.FileContractID, lastKnownRevisionNumber uint64, toKeep []types.Hash256) (revision *types.FileContractRevision, deleted, remaining uint64, cost types.Currency, err error) { err = c.withTransport(ctx, hostKey, hostIP, func(t *rhpv2.Transport) error { return c.withRevisionV2(renterKey, gougingChecker, t, fcid, lastKnownRevisionNumber, func(t *rhpv2.Transport, rev rhpv2.ContractRevision, settings rhpv2.HostSettings) (err error) { diff --git a/internal/test/e2e/cluster.go b/internal/test/e2e/cluster.go index 3e01e8ae7..08bc6866a 100644 --- a/internal/test/e2e/cluster.go +++ b/internal/test/e2e/cluster.go @@ -572,9 +572,13 @@ func newTestBus(ctx context.Context, dir string, cfg config.Bus, cfgDb dbConfig, } } + // create master key - we currently derive the same key used by the workers + // to ensure contracts formed by the bus can be renewed by the autopilot + masterKey := blake2b.Sum256(append([]byte("worker"), pk...)) + // create bus announcementMaxAgeHours := time.Duration(cfg.AnnouncementMaxAgeHours) * time.Hour - b, err := bus.New(ctx, alertsMgr, wh, cm, s, w, sqlStore, announcementMaxAgeHours, logger) + b, err := bus.New(ctx, masterKey, alertsMgr, wh, cm, s, w, sqlStore, announcementMaxAgeHours, logger) if err != nil { return nil, nil, nil, err } diff --git a/internal/test/e2e/rhp_test.go b/internal/test/e2e/rhp_test.go new file mode 100644 index 000000000..d6530998f --- /dev/null +++ b/internal/test/e2e/rhp_test.go @@ -0,0 +1,81 @@ +package e2e + +import ( + "context" + "fmt" + "testing" + "time" + + "go.sia.tech/core/types" + "go.sia.tech/renterd/api" + "go.sia.tech/renterd/internal/test" + "go.uber.org/zap/zapcore" +) + +func TestRHPForm(t *testing.T) { + // configure the autopilot not to form any contracts + apSettings := test.AutopilotConfig + apSettings.Contracts.Amount = 0 + + // create cluster + opts := clusterOptsDefault + opts.autopilotSettings = &apSettings + opts.logger = newTestLoggerCustom(zapcore.DebugLevel) + cluster := newTestCluster(t, opts) + defer cluster.Shutdown() + + // convenience variables + b := cluster.Bus + a := cluster.Autopilot + tt := cluster.tt + + // add a host + hosts := cluster.AddHosts(1) + h, err := b.Host(context.Background(), hosts[0].PublicKey()) + tt.OK(err) + + // form a contract using the bus + cs, _ := b.ConsensusState(context.Background()) + wallet, _ := b.Wallet(context.Background()) + fcid, err := b.RHPForm(context.Background(), cs.BlockHeight+test.AutopilotConfig.Contracts.Period+test.AutopilotConfig.Contracts.RenewWindow, h.PublicKey, h.NetAddress, wallet.Address, types.Siacoins(1), types.Siacoins(1)) + tt.OK(err) + + // assert the contract is added to the bus + _, err = b.Contract(context.Background(), fcid) + tt.OK(err) + + // mine to the renew window + cluster.MineToRenewWindow() + + // update autopilot config to allow for 1 contract, this won't form a + // contract but will ensure we don't skip contract maintenance, which should + // renew the contract we formed + apSettings.Contracts.Amount = 1 + tt.OK(a.UpdateConfig(apSettings)) + + // assert the contract gets renewed and thus maintained + var renewalID types.FileContractID + tt.Retry(100, 100*time.Millisecond, func() error { + contracts, err := cluster.Bus.Contracts(context.Background(), api.ContractsOpts{}) + if err != nil { + return err + } + if len(contracts) != 1 { + return fmt.Errorf("unexpected number of contracts %d != 1", len(contracts)) + } + if contracts[0].RenewedFrom != fcid { + return fmt.Errorf("contract wasn't renewed %v != %v", contracts[0].RenewedFrom, fcid) + } + renewalID = contracts[0].ID + return nil + }) + + // assert the contract is part of the contract set + contracts, err := b.Contracts(context.Background(), api.ContractsOpts{ContractSet: test.ContractSet}) + tt.OK(err) + if len(contracts) != 1 { + t.Fatalf("expected 1 contract, got %v", len(contracts)) + } else if contracts[0].ID != renewalID { + t.Fatalf("expected contract %v, got %v", fcid, contracts[0].ID) + } +} diff --git a/internal/utils/errors.go b/internal/utils/errors.go index 22ff0e660..30e1c767c 100644 --- a/internal/utils/errors.go +++ b/internal/utils/errors.go @@ -17,6 +17,11 @@ var ( ErrIOTimeout = errors.New("i/o timeout") ) +var ( + // ErrNotImplemented is returned when a function is not implemented. + ErrNotImplemented = errors.New("not implemented") +) + // IsErr can be used to compare an error to a target and also works when used on // errors that haven't been wrapped since it will fall back to a string // comparison. Useful to check errors returned over the network. diff --git a/worker/worker.go b/worker/worker.go index da2710cbc..604c66f68 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -27,6 +27,7 @@ import ( "go.sia.tech/renterd/build" "go.sia.tech/renterd/config" "go.sia.tech/renterd/internal/gouging" + "go.sia.tech/renterd/internal/rhp" rhp2 "go.sia.tech/renterd/internal/rhp/v2" rhp3 "go.sia.tech/renterd/internal/rhp/v3" "go.sia.tech/renterd/internal/utils" @@ -217,7 +218,7 @@ type Worker struct { uploadManager *uploadManager accounts *accounts - dialer *iworker.FallbackDialer + dialer *rhp.FallbackDialer cache iworker.WorkerCache priceTables *priceTables @@ -1287,7 +1288,7 @@ func New(cfg config.Worker, masterKey [32]byte, b Bus, l *zap.Logger) (*Worker, a := alerts.WithOrigin(b, fmt.Sprintf("worker.%s", cfg.ID)) shutdownCtx, shutdownCancel := context.WithCancel(context.Background()) - dialer := iworker.NewFallbackDialer(b, net.Dialer{}, l) + dialer := rhp.NewFallbackDialer(b, net.Dialer{}, l) w := &Worker{ alerts: a, allowPrivateIPs: cfg.AllowPrivateIPs, From 8944f4ba132196f98613c052e3e2f90b6641da12 Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 21 Aug 2024 12:12:44 +0200 Subject: [PATCH 02/41] internal: panic when forming v2 contracts --- bus/routes.go | 37 +++---------------------------------- internal/rhp/v2/rhp.go | 5 ----- internal/utils/errors.go | 5 ----- 3 files changed, 3 insertions(+), 44 deletions(-) diff --git a/bus/routes.go b/bus/routes.go index 82272aeb6..c18b906eb 100644 --- a/bus/routes.go +++ b/bus/routes.go @@ -2323,39 +2323,7 @@ func (b *Bus) rhpFormHandler(jc jape.Context) { // send V2 transaction if we're passed the V2 hardfork allow height var contract rhpv2.ContractRevision if b.isPassedV2AllowHeight() { - // form the contract - var txnSet []types.V2Transaction - contract, txnSet, err = b.rhp2.FormV2Contract( - ctx, - rfr.RenterAddress, - b.deriveRenterKey(rfr.HostKey), - rfr.HostKey, - rfr.HostIP, - rfr.RenterFunds, - rfr.HostCollateral, - rfr.EndHeight, - gc, - b.prepareFormV2, - ) - if errors.Is(err, utils.ErrNotImplemented) { - jc.Error(err, http.StatusNotImplemented) // TODO: remove once rhp4 is implemented - return - } else if jc.Check("couldn't form contract", err) != nil { - return - } - - // fetch state - state := b.cm.TipState() - - // add transaction set to the pool - _, err := b.cm.AddV2PoolTransactions(state.Index, txnSet) - if jc.Check("couldn't broadcast transaction set", err) != nil { - b.w.ReleaseInputs(nil, txnSet) - return - } - - // broadcast the transaction set - b.s.BroadcastV2TransactionSet(state.Index, txnSet) + panic("not implemented") } else { // form the contract var txnSet []types.Transaction @@ -2427,7 +2395,8 @@ func (b *Bus) prepareForm(ctx context.Context, renterAddress types.Address, rent return txns, func(txn types.Transaction) { b.w.ReleaseInputs(txns, nil) }, nil } -func (b *Bus) prepareFormV2(ctx context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) ([]types.V2Transaction, func(types.V2Transaction), error) { +// nolint: unused +func (b *Bus) prepareFormV2(_ context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) ([]types.V2Transaction, func(types.V2Transaction), error) { hostFunds := hostSettings.ContractPrice.Add(hostCollateral) // prepare the transaction diff --git a/internal/rhp/v2/rhp.go b/internal/rhp/v2/rhp.go index 01b0a56cc..8b2c1da6e 100644 --- a/internal/rhp/v2/rhp.go +++ b/internal/rhp/v2/rhp.go @@ -184,11 +184,6 @@ func (c *Client) FormContract(ctx context.Context, renterAddress types.Address, return } -func (c *Client) FormV2Contract(ctx context.Context, renterAddress types.Address, renterKey types.PrivateKey, hostKey types.PublicKey, hostIP string, renterFunds, hostCollateral types.Currency, endHeight uint64, gougingChecker gouging.Checker, prepareForm PrepareV2FormFn) (contract rhpv2.ContractRevision, txnSet []types.V2Transaction, err error) { - err = fmt.Errorf("%w; forming contracts using V2 transactions is not supported yet", utils.ErrNotImplemented) - return -} - func (c *Client) PruneContract(ctx context.Context, renterKey types.PrivateKey, gougingChecker gouging.Checker, hostIP string, hostKey types.PublicKey, fcid types.FileContractID, lastKnownRevisionNumber uint64, toKeep []types.Hash256) (revision *types.FileContractRevision, deleted, remaining uint64, cost types.Currency, err error) { err = c.withTransport(ctx, hostKey, hostIP, func(t *rhpv2.Transport) error { return c.withRevisionV2(renterKey, gougingChecker, t, fcid, lastKnownRevisionNumber, func(t *rhpv2.Transport, rev rhpv2.ContractRevision, settings rhpv2.HostSettings) (err error) { diff --git a/internal/utils/errors.go b/internal/utils/errors.go index 30e1c767c..22ff0e660 100644 --- a/internal/utils/errors.go +++ b/internal/utils/errors.go @@ -17,11 +17,6 @@ var ( ErrIOTimeout = errors.New("i/o timeout") ) -var ( - // ErrNotImplemented is returned when a function is not implemented. - ErrNotImplemented = errors.New("not implemented") -) - // IsErr can be used to compare an error to a target and also works when used on // errors that haven't been wrapped since it will fall back to a string // comparison. Useful to check errors returned over the network. From 56953d8daad263d09105a863119c5cfc8e58b6b5 Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 21 Aug 2024 13:18:39 +0200 Subject: [PATCH 03/41] bus,worker: remove legacy formation endpoints --- api/wallet.go | 13 --- autopilot/autopilot.go | 2 +- autopilot/contractor/contractor.go | 17 +--- autopilot/workerpool.go | 2 - bus/bus.go | 1 - bus/client/contracts.go | 25 +++--- bus/client/rhp.go | 21 ----- bus/client/wallet.go | 16 ---- bus/routes.go | 127 ++++++++--------------------- internal/rhp/v2/rhp.go | 25 +----- internal/test/e2e/cluster.go | 26 +++--- internal/test/e2e/cluster_test.go | 12 ++- internal/test/e2e/gouging_test.go | 5 +- internal/test/e2e/rhp_test.go | 13 +-- worker/client/rhp.go | 17 ---- worker/mocks_test.go | 4 - worker/worker.go | 57 ------------- 17 files changed, 81 insertions(+), 302 deletions(-) delete mode 100644 bus/client/rhp.go diff --git a/api/wallet.go b/api/wallet.go index 510e7b95b..d2ddbc857 100644 --- a/api/wallet.go +++ b/api/wallet.go @@ -5,7 +5,6 @@ import ( "net/url" "time" - rhpv2 "go.sia.tech/core/rhp/v2" rhpv3 "go.sia.tech/core/rhp/v3" "go.sia.tech/core/types" ) @@ -45,18 +44,6 @@ type ( DependsOn []types.Transaction `json:"dependsOn"` } - // WalletPrepareFormRequest is the request type for the /wallet/prepare/form - // endpoint. - WalletPrepareFormRequest struct { - EndHeight uint64 `json:"endHeight"` - HostCollateral types.Currency `json:"hostCollateral"` - HostKey types.PublicKey `json:"hostKey"` - HostSettings rhpv2.HostSettings `json:"hostSettings"` - RenterAddress types.Address `json:"renterAddress"` - RenterFunds types.Currency `json:"renterFunds"` - RenterKey types.PublicKey `json:"renterKey"` - } - // WalletPrepareRenewRequest is the request type for the /wallet/prepare/renew // endpoint. WalletPrepareRenewRequest struct { diff --git a/autopilot/autopilot.go b/autopilot/autopilot.go index 58fb0a9ec..9ea235a11 100644 --- a/autopilot/autopilot.go +++ b/autopilot/autopilot.go @@ -42,13 +42,13 @@ type Bus interface { ConsensusState(ctx context.Context) (api.ConsensusState, error) // contracts - AddContract(ctx context.Context, c rhpv2.ContractRevision, contractPrice, totalCost types.Currency, startHeight uint64, state string) (api.ContractMetadata, error) AddRenewedContract(ctx context.Context, c rhpv2.ContractRevision, contractPrice, totalCost types.Currency, startHeight uint64, renewedFrom types.FileContractID, state string) (api.ContractMetadata, error) AncestorContracts(ctx context.Context, id types.FileContractID, minStartHeight uint64) ([]api.ArchivedContract, error) ArchiveContracts(ctx context.Context, toArchive map[types.FileContractID]string) error Contract(ctx context.Context, id types.FileContractID) (api.ContractMetadata, error) Contracts(ctx context.Context, opts api.ContractsOpts) (contracts []api.ContractMetadata, err error) FileContractTax(ctx context.Context, payout types.Currency) (types.Currency, error) + FormContract(ctx context.Context, renterAddress types.Address, renterFunds types.Currency, hostKey types.PublicKey, hostIP string, hostCollateral types.Currency, endHeight uint64) (api.ContractMetadata, error) SetContractSet(ctx context.Context, set string, contracts []types.FileContractID) error PrunableData(ctx context.Context) (prunableData api.ContractsPrunableDataResponse, err error) diff --git a/autopilot/contractor/contractor.go b/autopilot/contractor/contractor.go index e82253d43..4e1b87d3b 100644 --- a/autopilot/contractor/contractor.go +++ b/autopilot/contractor/contractor.go @@ -81,7 +81,6 @@ const ( ) type Bus interface { - AddContract(ctx context.Context, c rhpv2.ContractRevision, contractPrice, totalCost types.Currency, startHeight uint64, state string) (api.ContractMetadata, error) AddRenewedContract(ctx context.Context, c rhpv2.ContractRevision, contractPrice, totalCost types.Currency, startHeight uint64, renewedFrom types.FileContractID, state string) (api.ContractMetadata, error) AncestorContracts(ctx context.Context, id types.FileContractID, minStartHeight uint64) ([]api.ArchivedContract, error) ArchiveContracts(ctx context.Context, toArchive map[types.FileContractID]string) error @@ -89,6 +88,7 @@ type Bus interface { Contract(ctx context.Context, id types.FileContractID) (api.ContractMetadata, error) Contracts(ctx context.Context, opts api.ContractsOpts) (contracts []api.ContractMetadata, err error) FileContractTax(ctx context.Context, payout types.Currency) (types.Currency, error) + FormContract(ctx context.Context, renterAddress types.Address, renterFunds types.Currency, hostKey types.PublicKey, hostIP string, hostCollateral types.Currency, endHeight uint64) (api.ContractMetadata, error) Host(ctx context.Context, hostKey types.PublicKey) (api.Host, error) RecordContractSetChurnMetric(ctx context.Context, metrics ...api.ContractSetChurnMetric) error SearchHosts(ctx context.Context, opts api.SearchHostOptions) ([]api.Host, error) @@ -99,7 +99,6 @@ type Bus interface { type Worker interface { Contracts(ctx context.Context, hostTimeout time.Duration) (api.ContractsResponse, error) RHPBroadcast(ctx context.Context, fcid types.FileContractID) (err error) - RHPForm(ctx context.Context, endHeight uint64, hk types.PublicKey, hostIP string, renterAddress types.Address, renterFunds types.Currency, hostCollateral types.Currency) (rhpv2.ContractRevision, []types.Transaction, error) RHPPriceTable(ctx context.Context, hostKey types.PublicKey, siamuxAddr string, timeout time.Duration) (api.HostPriceTable, error) RHPRenew(ctx context.Context, fcid types.FileContractID, endHeight uint64, hk types.PublicKey, hostIP string, hostAddress, renterAddress types.Address, renterFunds, minNewCollateral, maxFundAmount types.Currency, expectedNewStorage, windowSize uint64) (api.RHPRenewResponse, error) RHPScan(ctx context.Context, hostKey types.PublicKey, hostIP string, timeout time.Duration) (api.RHPScanResponse, error) @@ -228,7 +227,7 @@ func (c *Contractor) formContract(ctx *mCtx, w Worker, host api.Host, minInitial hostCollateral := rhpv2.ContractFormationCollateral(ctx.Period(), expectedStorage, scan.Settings) // form contract - contract, _, err := w.RHPForm(ctx, endHeight, hk, host.NetAddress, ctx.state.Address, renterFunds, hostCollateral) + contract, err := c.bus.FormContract(ctx, ctx.state.Address, renterFunds, hk, host.NetAddress, hostCollateral, endHeight) if err != nil { // TODO: keep track of consecutive failures and break at some point logger.Errorw(fmt.Sprintf("contract formation failed, err: %v", err), "hk", hk) @@ -241,20 +240,12 @@ func (c *Contractor) formContract(ctx *mCtx, w Worker, host api.Host, minInitial // update the budget *budget = budget.Sub(renterFunds) - // persist contract in store - contractPrice := contract.Revision.MissedHostPayout().Sub(hostCollateral) - formedContract, err := c.bus.AddContract(ctx, contract, contractPrice, renterFunds, cs.BlockHeight, api.ContractStatePending) - if err != nil { - logger.Errorw(fmt.Sprintf("contract formation failed, err: %v", err), "hk", hk) - return api.ContractMetadata{}, true, err - } - logger.Infow("formation succeeded", - "fcid", formedContract.ID, + "fcid", contract.ID, "renterFunds", renterFunds.String(), "collateral", hostCollateral.String(), ) - return formedContract, true, nil + return contract, true, nil } func (c *Contractor) initialContractFunding(settings rhpv2.HostSettings, txnFee, minFunding, maxFunding types.Currency) types.Currency { diff --git a/autopilot/workerpool.go b/autopilot/workerpool.go index 990498e62..acc6d22e2 100644 --- a/autopilot/workerpool.go +++ b/autopilot/workerpool.go @@ -5,7 +5,6 @@ import ( "sync" "time" - rhpv2 "go.sia.tech/core/rhp/v2" rhpv3 "go.sia.tech/core/rhp/v3" "go.sia.tech/core/types" "go.sia.tech/renterd/api" @@ -20,7 +19,6 @@ type Worker interface { MigrateSlab(ctx context.Context, s object.Slab, set string) (api.MigrateSlabResponse, error) RHPBroadcast(ctx context.Context, fcid types.FileContractID) (err error) - RHPForm(ctx context.Context, endHeight uint64, hk types.PublicKey, hostIP string, renterAddress types.Address, renterFunds types.Currency, hostCollateral types.Currency) (rhpv2.ContractRevision, []types.Transaction, error) RHPFund(ctx context.Context, contractID types.FileContractID, hostKey types.PublicKey, hostIP, siamuxAddr string, balance types.Currency) (err error) RHPPriceTable(ctx context.Context, hostKey types.PublicKey, siamuxAddr string, timeout time.Duration) (api.HostPriceTable, error) RHPPruneContract(ctx context.Context, fcid types.FileContractID, timeout time.Duration) (pruned, remaining uint64, err error) diff --git a/bus/bus.go b/bus/bus.go index 3a2ad570a..7c61f9144 100644 --- a/bus/bus.go +++ b/bus/bus.go @@ -518,7 +518,6 @@ func (b *Bus) Handler() http.Handler { "POST /wallet/fund": b.walletFundHandler, "GET /wallet/outputs": b.walletOutputsHandler, "GET /wallet/pending": b.walletPendingHandler, - "POST /wallet/prepare/form": b.walletPrepareFormHandler, "POST /wallet/prepare/renew": b.walletPrepareRenewHandler, "POST /wallet/redistribute": b.walletRedistributeHandler, "POST /wallet/send": b.walletSendSiacoinsHandler, diff --git a/bus/client/contracts.go b/bus/client/contracts.go index 84cd7dc88..d1c2d8006 100644 --- a/bus/client/contracts.go +++ b/bus/client/contracts.go @@ -11,18 +11,6 @@ import ( "go.sia.tech/renterd/api" ) -// AddContract adds the provided contract to the metadata store. -func (c *Client) AddContract(ctx context.Context, contract rhpv2.ContractRevision, contractPrice, totalCost types.Currency, startHeight uint64, state string) (added api.ContractMetadata, err error) { - err = c.c.WithContext(ctx).POST(fmt.Sprintf("/contract/%s", contract.ID()), api.ContractAddRequest{ - Contract: contract, - StartHeight: startHeight, - ContractPrice: contractPrice, - State: state, - TotalCost: totalCost, - }, &added) - return -} - // AddRenewedContract adds the provided contract to the metadata store. func (c *Client) AddRenewedContract(ctx context.Context, contract rhpv2.ContractRevision, contractPrice, totalCost types.Currency, startHeight uint64, renewedFrom types.FileContractID, state string) (renewed api.ContractMetadata, err error) { err = c.c.WithContext(ctx).POST(fmt.Sprintf("/contract/%s/renewed", contract.ID()), api.ContractRenewedRequest{ @@ -130,6 +118,19 @@ func (c *Client) DeleteContractSet(ctx context.Context, set string) (err error) return } +// FormContract forms a contract with a host and adds it to the bus. +func (c *Client) FormContract(ctx context.Context, renterAddress types.Address, renterFunds types.Currency, hostKey types.PublicKey, hostIP string, hostCollateral types.Currency, endHeight uint64) (contract api.ContractMetadata, err error) { + err = c.c.WithContext(ctx).POST("/rhp/form", api.RHPFormRequest{ + EndHeight: endHeight, + HostCollateral: hostCollateral, + HostKey: hostKey, + HostIP: hostIP, + RenterFunds: renterFunds, + RenterAddress: renterAddress, + }, &contract) + return +} + // KeepaliveContract extends the duration on an already acquired lock on a // contract. func (c *Client) KeepaliveContract(ctx context.Context, contractID types.FileContractID, lockID uint64, d time.Duration) (err error) { diff --git a/bus/client/rhp.go b/bus/client/rhp.go deleted file mode 100644 index 52c82cd4c..000000000 --- a/bus/client/rhp.go +++ /dev/null @@ -1,21 +0,0 @@ -package client - -import ( - "context" - - "go.sia.tech/core/types" - "go.sia.tech/renterd/api" -) - -// RHPForm forms a contract with a host and adds it to the bus. -func (c *Client) RHPForm(ctx context.Context, endHeight uint64, hostKey types.PublicKey, hostIP string, renterAddress types.Address, renterFunds types.Currency, hostCollateral types.Currency) (contractID types.FileContractID, err error) { - err = c.c.WithContext(ctx).POST("/rhp/form", api.RHPFormRequest{ - EndHeight: endHeight, - HostCollateral: hostCollateral, - HostKey: hostKey, - HostIP: hostIP, - RenterFunds: renterFunds, - RenterAddress: renterAddress, - }, &contractID) - return -} diff --git a/bus/client/wallet.go b/bus/client/wallet.go index 9733ed335..0fcc8d0b5 100644 --- a/bus/client/wallet.go +++ b/bus/client/wallet.go @@ -6,7 +6,6 @@ import ( "net/http" "net/url" - rhpv2 "go.sia.tech/core/rhp/v2" rhpv3 "go.sia.tech/core/rhp/v3" "go.sia.tech/core/types" "go.sia.tech/renterd/api" @@ -64,21 +63,6 @@ func (c *Client) WalletPending(ctx context.Context) (resp []types.Transaction, e return } -// WalletPrepareForm funds and signs a contract transaction. -func (c *Client) WalletPrepareForm(ctx context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) (txns []types.Transaction, err error) { - req := api.WalletPrepareFormRequest{ - EndHeight: endHeight, - HostCollateral: hostCollateral, - HostKey: hostKey, - HostSettings: hostSettings, - RenterAddress: renterAddress, - RenterFunds: renterFunds, - RenterKey: renterKey, - } - err = c.c.WithContext(ctx).POST("/wallet/prepare/form", req, &txns) - return -} - // WalletPrepareRenew funds and signs a contract renewal transaction. func (c *Client) WalletPrepareRenew(ctx context.Context, revision types.FileContractRevision, hostAddress, renterAddress types.Address, renterKey types.PrivateKey, renterFunds, minNewCollateral, maxFundAmount types.Currency, pt rhpv3.HostPriceTable, endHeight, windowSize, expectedStorage uint64) (api.WalletPrepareRenewResponse, error) { req := api.WalletPrepareRenewRequest{ diff --git a/bus/routes.go b/bus/routes.go index c18b906eb..ef882710d 100644 --- a/bus/routes.go +++ b/bus/routes.go @@ -456,37 +456,6 @@ func (b *Bus) walletDiscardHandler(jc jape.Context) { } } -func (b *Bus) walletPrepareFormHandler(jc jape.Context) { - var wpfr api.WalletPrepareFormRequest - if jc.Decode(&wpfr) != nil { - return - } - if wpfr.HostKey == (types.PublicKey{}) { - jc.Error(errors.New("no host key provided"), http.StatusBadRequest) - return - } - if wpfr.RenterKey == (types.PublicKey{}) { - jc.Error(errors.New("no renter key provided"), http.StatusBadRequest) - return - } - - if txns, _, err := b.prepareForm( - jc.Request.Context(), - wpfr.RenterAddress, - wpfr.RenterKey, - wpfr.RenterFunds, - wpfr.HostCollateral, - wpfr.HostKey, - wpfr.HostSettings, - wpfr.EndHeight, - ); err != nil { - jc.Error(err, http.StatusInternalServerError) - return - } else { - jc.Encode(txns) - } -} - func (b *Bus) walletPrepareRenewHandler(jc jape.Context) { var wprr api.WalletPrepareRenewRequest if jc.Decode(&wprr) != nil { @@ -2320,26 +2289,37 @@ func (b *Bus) rhpFormHandler(jc jape.Context) { } gc := gouging.NewChecker(gp.GougingSettings, gp.ConsensusState, gp.TransactionFee, nil, nil) + // fetch host settings + settings, err := b.rhp2.Settings(ctx, rfr.HostKey, rfr.HostIP) + if jc.Check("couldn't fetch host settings", err) != nil { + return + } + + // check gouging + breakdown := gc.CheckSettings(settings) + if breakdown.Gouging() { + jc.Error(fmt.Errorf("failed to form contract, gouging check failed: %v", breakdown), http.StatusBadRequest) + return + } + // send V2 transaction if we're passed the V2 hardfork allow height - var contract rhpv2.ContractRevision + var revision rhpv2.ContractRevision if b.isPassedV2AllowHeight() { panic("not implemented") } else { - // form the contract var txnSet []types.Transaction - contract, txnSet, err = b.rhp2.FormContract( + revision, txnSet, err = b.formContract( ctx, + settings, rfr.RenterAddress, - b.deriveRenterKey(rfr.HostKey), - rfr.HostKey, - rfr.HostIP, rfr.RenterFunds, rfr.HostCollateral, + rfr.HostKey, + rfr.HostIP, rfr.EndHeight, - gc, - b.prepareForm, ) if jc.Check("couldn't form contract", err) != nil { + b.w.ReleaseInputs(txnSet, nil) return } @@ -2355,10 +2335,10 @@ func (b *Bus) rhpFormHandler(jc jape.Context) { } // store the contract - _, err = b.ms.AddContract( + contract, err := b.ms.AddContract( ctx, - contract, - contract.Revision.MissedHostPayout().Sub(rfr.HostCollateral), + revision, + revision.Revision.MissedHostPayout().Sub(rfr.HostCollateral), rfr.RenterFunds, b.cm.Tip().Height, api.ContractStatePending, @@ -2367,14 +2347,17 @@ func (b *Bus) rhpFormHandler(jc jape.Context) { return } - // return the contract ID - jc.Encode(contract.ID()) + // return the contract + jc.Encode(contract) } -func (b *Bus) prepareForm(ctx context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) ([]types.Transaction, func(types.Transaction), error) { +func (b *Bus) formContract(ctx context.Context, hostSettings rhpv2.HostSettings, renterAddress types.Address, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostIP string, endHeight uint64) (rhpv2.ContractRevision, []types.Transaction, error) { + // derive the renter key + renterKey := b.deriveRenterKey(hostKey) + // prepare the transaction cs := b.cm.TipState() - fc := rhpv2.PrepareContractFormation(renterKey, hostKey, renterFunds, hostCollateral, endHeight, hostSettings, renterAddress) + fc := rhpv2.PrepareContractFormation(renterKey.PublicKey(), hostKey, renterFunds, hostCollateral, endHeight, hostSettings, renterAddress) txn := types.Transaction{FileContracts: []types.FileContract{fc}} // calculate the miner fee @@ -2385,59 +2368,15 @@ func (b *Bus) prepareForm(ctx context.Context, renterAddress types.Address, rent cost := rhpv2.ContractFormationCost(cs, fc, hostSettings.ContractPrice).Add(fee) toSign, err := b.w.FundTransaction(&txn, cost, true) if err != nil { - return nil, nil, fmt.Errorf("couldn't fund transaction: %w", err) + return rhpv2.ContractRevision{}, nil, fmt.Errorf("couldn't fund transaction: %w", err) } // sign the transaction b.w.SignTransaction(&txn, toSign, wallet.ExplicitCoveredFields(txn)) - txns := append(b.cm.UnconfirmedParents(txn), txn) - return txns, func(txn types.Transaction) { b.w.ReleaseInputs(txns, nil) }, nil -} - -// nolint: unused -func (b *Bus) prepareFormV2(_ context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) ([]types.V2Transaction, func(types.V2Transaction), error) { - hostFunds := hostSettings.ContractPrice.Add(hostCollateral) - - // prepare the transaction - cs := b.cm.TipState() - fc := types.V2FileContract{ - RevisionNumber: 0, - Filesize: 0, - FileMerkleRoot: types.Hash256{}, - ProofHeight: endHeight + hostSettings.WindowSize, - ExpirationHeight: endHeight + hostSettings.WindowSize + 10, - RenterOutput: types.SiacoinOutput{ - Value: renterFunds, - Address: renterAddress, - }, - HostOutput: types.SiacoinOutput{ - Value: hostFunds, - Address: hostSettings.Address, - }, - MissedHostValue: hostFunds, - TotalCollateral: hostFunds, - RenterPublicKey: renterKey, - HostPublicKey: hostKey, - } - txn := types.V2Transaction{FileContracts: []types.V2FileContract{fc}} - - // calculate the miner fee - fee := b.cm.RecommendedFee().Mul64(cs.V2TransactionWeight(txn)) - txn.MinerFee = fee - - // fund the transaction - fundAmount := cs.V2FileContractTax(fc).Add(hostFunds).Add(renterFunds).Add(fee) - cs, toSign, err := b.w.FundV2Transaction(&txn, fundAmount, false) - if err != nil { - return nil, nil, fmt.Errorf("couldn't fund transaction: %w", err) - } - - // sign the transaction - b.w.SignV2Inputs(cs, &txn, toSign) - - txns := append(b.cm.V2UnconfirmedParents(txn), txn) - return txns, func(txn types.V2Transaction) { b.w.ReleaseInputs(nil, txns) }, nil + // form the contract + txnSet := append(b.cm.UnconfirmedParents(txn), txn) + return b.rhp2.FormContract(ctx, hostKey, hostIP, renterKey, txnSet) } func (b *Bus) isPassedV2AllowHeight() bool { diff --git a/internal/rhp/v2/rhp.go b/internal/rhp/v2/rhp.go index 8b2c1da6e..c2454c13d 100644 --- a/internal/rhp/v2/rhp.go +++ b/internal/rhp/v2/rhp.go @@ -71,9 +71,6 @@ type ( Dialer interface { Dial(ctx context.Context, hk types.PublicKey, address string) (net.Conn, error) } - - PrepareFormFn func(ctx context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) (txns []types.Transaction, discard func(types.Transaction), err error) - PrepareV2FormFn func(ctx context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) (txns []types.V2Transaction, discard func(types.V2Transaction), err error) ) type Client struct { @@ -158,27 +155,9 @@ func (c *Client) Settings(ctx context.Context, hostKey types.PublicKey, hostIP s return } -func (c *Client) FormContract(ctx context.Context, renterAddress types.Address, renterKey types.PrivateKey, hostKey types.PublicKey, hostIP string, renterFunds, hostCollateral types.Currency, endHeight uint64, gougingChecker gouging.Checker, prepareForm PrepareFormFn) (contract rhpv2.ContractRevision, txnSet []types.Transaction, err error) { +func (c *Client) FormContract(ctx context.Context, hostKey types.PublicKey, hostIP string, renterKey types.PrivateKey, txnSet []types.Transaction) (contract rhpv2.ContractRevision, fullTxnSet []types.Transaction, err error) { err = c.withTransport(ctx, hostKey, hostIP, func(t *rhpv2.Transport) (err error) { - settings, err := rpcSettings(ctx, t) - if err != nil { - return err - } - - if breakdown := gougingChecker.CheckSettings(settings); breakdown.Gouging() { - return fmt.Errorf("failed to form contract, gouging check failed: %v", breakdown) - } - - renterTxnSet, discardTxn, err := prepareForm(ctx, renterAddress, renterKey.PublicKey(), renterFunds, hostCollateral, hostKey, settings, endHeight) - if err != nil { - return err - } - - contract, txnSet, err = rpcFormContract(ctx, t, renterKey, renterTxnSet) - if err != nil { - discardTxn(renterTxnSet[len(renterTxnSet)-1]) - return err - } + contract, fullTxnSet, err = rpcFormContract(ctx, t, renterKey, txnSet) return }) return diff --git a/internal/test/e2e/cluster.go b/internal/test/e2e/cluster.go index 08bc6866a..b500643d3 100644 --- a/internal/test/e2e/cluster.go +++ b/internal/test/e2e/cluster.go @@ -71,6 +71,7 @@ type TestCluster struct { network *consensus.Network genesisBlock types.Block + bs bus.Store cm *chain.Manager apID string dbName string @@ -313,7 +314,7 @@ func newTestCluster(t *testing.T, opts testClusterOptions) *TestCluster { // Create bus. busDir := filepath.Join(dir, "bus") - b, bShutdownFn, cm, err := newTestBus(ctx, busDir, busCfg, dbCfg, wk, logger) + b, bShutdownFn, cm, bs, err := newTestBus(ctx, busDir, busCfg, dbCfg, wk, logger) tt.OK(err) busAuth := jape.BasicAuth(busPassword) @@ -371,6 +372,7 @@ func newTestCluster(t *testing.T, opts testClusterOptions) *TestCluster { logger: logger, network: network, genesisBlock: genesis, + bs: bs, cm: cm, tt: tt, wk: wk, @@ -484,23 +486,23 @@ func newTestCluster(t *testing.T, opts testClusterOptions) *TestCluster { return cluster } -func newTestBus(ctx context.Context, dir string, cfg config.Bus, cfgDb dbConfig, pk types.PrivateKey, logger *zap.Logger) (*bus.Bus, func(ctx context.Context) error, *chain.Manager, error) { +func newTestBus(ctx context.Context, dir string, cfg config.Bus, cfgDb dbConfig, pk types.PrivateKey, logger *zap.Logger) (*bus.Bus, func(ctx context.Context) error, *chain.Manager, bus.Store, error) { // create store alertsMgr := alerts.NewManager() storeCfg, err := buildStoreConfig(alertsMgr, dir, cfg.SlabBufferCompletionThreshold, cfgDb, pk, logger) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } sqlStore, err := stores.NewSQLStore(storeCfg) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } // create webhooks manager wh, err := webhooks.NewManager(sqlStore, logger) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } // hookup webhooks <-> alerts @@ -509,35 +511,35 @@ func newTestBus(ctx context.Context, dir string, cfg config.Bus, cfgDb dbConfig, // create consensus directory consensusDir := filepath.Join(dir, "consensus") if err := os.MkdirAll(consensusDir, 0700); err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } // create chain database chainPath := filepath.Join(consensusDir, "blockchain.db") bdb, err := coreutils.OpenBoltChainDB(chainPath) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } // create chain manager network, genesis := testNetwork() store, state, err := chain.NewDBStore(bdb, network, genesis) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } cm := chain.NewManager(store, state) // create wallet w, err := wallet.NewSingleAddressWallet(pk, cm, sqlStore, wallet.WithReservationDuration(cfg.UsedUTXOExpiry)) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } // create syncer, peers will reject us if our hostname is empty or // unspecified, so use loopback l, err := net.Listen("tcp", cfg.GatewayAddr) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } syncerAddr := l.Addr().String() host, port, _ := net.SplitHostPort(syncerAddr) @@ -580,7 +582,7 @@ func newTestBus(ctx context.Context, dir string, cfg config.Bus, cfgDb dbConfig, announcementMaxAgeHours := time.Duration(cfg.AnnouncementMaxAgeHours) * time.Hour b, err := bus.New(ctx, masterKey, alertsMgr, wh, cm, s, w, sqlStore, announcementMaxAgeHours, logger) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } shutdownFn := func(ctx context.Context) error { @@ -593,7 +595,7 @@ func newTestBus(ctx context.Context, dir string, cfg config.Bus, cfgDb dbConfig, syncerShutdown(ctx), ) } - return b, shutdownFn, cm, nil + return b, shutdownFn, cm, sqlStore, nil } // addStorageFolderToHosts adds a single storage folder to each host. diff --git a/internal/test/e2e/cluster_test.go b/internal/test/e2e/cluster_test.go index a92f8a36b..054b6f885 100644 --- a/internal/test/e2e/cluster_test.go +++ b/internal/test/e2e/cluster_test.go @@ -1090,9 +1090,8 @@ func TestContractApplyChainUpdates(t *testing.T) { // manually form a contract with the host cs, _ := b.ConsensusState(context.Background()) wallet, _ := b.Wallet(context.Background()) - rev, _, err := w.RHPForm(context.Background(), cs.BlockHeight+test.AutopilotConfig.Contracts.Period+test.AutopilotConfig.Contracts.RenewWindow, h.PublicKey, h.NetAddress, wallet.Address, types.Siacoins(1), types.Siacoins(1)) - tt.OK(err) - contract, err := b.AddContract(context.Background(), rev, rev.Revision.MissedHostPayout().Sub(types.Siacoins(1)), types.Siacoins(1), cs.BlockHeight, api.ContractStatePending) + endHeight := cs.BlockHeight + test.AutopilotConfig.Contracts.Period + test.AutopilotConfig.Contracts.RenewWindow + contract, err := b.FormContract(context.Background(), wallet.Address, types.Siacoins(1), h.PublicKey, h.NetAddress, types.Siacoins(1), endHeight) tt.OK(err) // assert revision height is 0 @@ -1101,13 +1100,12 @@ func TestContractApplyChainUpdates(t *testing.T) { } // broadcast the revision for each contract - fcid := contract.ID - tt.OK(w.RHPBroadcast(context.Background(), fcid)) + tt.OK(w.RHPBroadcast(context.Background(), contract.ID)) cluster.MineBlocks(1) // check the revision height was updated. tt.Retry(100, 100*time.Millisecond, func() error { - c, err := cluster.Bus.Contract(context.Background(), fcid) + c, err := cluster.Bus.Contract(context.Background(), contract.ID) tt.OK(err) if c.RevisionHeight == 0 { return fmt.Errorf("contract %v should have been revised", c.ID) @@ -1554,7 +1552,7 @@ func TestUnconfirmedContractArchival(t *testing.T) { c := contracts[0] // add a contract to the bus - _, err = cluster.Bus.AddContract(context.Background(), rhpv2.ContractRevision{ + _, err = cluster.bs.AddContract(context.Background(), rhpv2.ContractRevision{ Revision: types.FileContractRevision{ ParentID: types.FileContractID{1}, UnlockConditions: types.UnlockConditions{ diff --git a/internal/test/e2e/gouging_test.go b/internal/test/e2e/gouging_test.go index a40fe0024..5be1784cb 100644 --- a/internal/test/e2e/gouging_test.go +++ b/internal/test/e2e/gouging_test.go @@ -170,9 +170,8 @@ func TestAccountFunding(t *testing.T) { // manually form a contract with the host cs, _ := b.ConsensusState(context.Background()) wallet, _ := b.Wallet(context.Background()) - rev, _, err := w.RHPForm(context.Background(), cs.BlockHeight+test.AutopilotConfig.Contracts.Period+test.AutopilotConfig.Contracts.RenewWindow, h.PublicKey, h.NetAddress, wallet.Address, types.Siacoins(1), types.Siacoins(1)) - tt.OK(err) - c, err := b.AddContract(context.Background(), rev, rev.Revision.MissedHostPayout().Sub(types.Siacoins(1)), types.Siacoins(1), cs.BlockHeight, api.ContractStatePending) + endHeight := cs.BlockHeight + test.AutopilotConfig.Contracts.Period + test.AutopilotConfig.Contracts.RenewWindow + c, err := b.FormContract(context.Background(), wallet.Address, types.Siacoins(1), h.PublicKey, h.NetAddress, types.Siacoins(1), endHeight) tt.OK(err) // fund the account diff --git a/internal/test/e2e/rhp_test.go b/internal/test/e2e/rhp_test.go index d6530998f..08f97b5e1 100644 --- a/internal/test/e2e/rhp_test.go +++ b/internal/test/e2e/rhp_test.go @@ -37,11 +37,12 @@ func TestRHPForm(t *testing.T) { // form a contract using the bus cs, _ := b.ConsensusState(context.Background()) wallet, _ := b.Wallet(context.Background()) - fcid, err := b.RHPForm(context.Background(), cs.BlockHeight+test.AutopilotConfig.Contracts.Period+test.AutopilotConfig.Contracts.RenewWindow, h.PublicKey, h.NetAddress, wallet.Address, types.Siacoins(1), types.Siacoins(1)) + endHeight := cs.BlockHeight + test.AutopilotConfig.Contracts.Period + test.AutopilotConfig.Contracts.RenewWindow + contract, err := b.FormContract(context.Background(), wallet.Address, types.Siacoins(1), h.PublicKey, h.NetAddress, types.Siacoins(1), endHeight) tt.OK(err) - // assert the contract is added to the bus - _, err = b.Contract(context.Background(), fcid) + // assert the contract was added to the bus + _, err = b.Contract(context.Background(), contract.ID) tt.OK(err) // mine to the renew window @@ -63,8 +64,8 @@ func TestRHPForm(t *testing.T) { if len(contracts) != 1 { return fmt.Errorf("unexpected number of contracts %d != 1", len(contracts)) } - if contracts[0].RenewedFrom != fcid { - return fmt.Errorf("contract wasn't renewed %v != %v", contracts[0].RenewedFrom, fcid) + if contracts[0].RenewedFrom != contract.ID { + return fmt.Errorf("contract wasn't renewed %v != %v", contracts[0].RenewedFrom, contract.ID) } renewalID = contracts[0].ID return nil @@ -76,6 +77,6 @@ func TestRHPForm(t *testing.T) { if len(contracts) != 1 { t.Fatalf("expected 1 contract, got %v", len(contracts)) } else if contracts[0].ID != renewalID { - t.Fatalf("expected contract %v, got %v", fcid, contracts[0].ID) + t.Fatalf("expected contract %v, got %v", contract.ID, contracts[0].ID) } } diff --git a/worker/client/rhp.go b/worker/client/rhp.go index d1fb2d9e8..65b939f47 100644 --- a/worker/client/rhp.go +++ b/worker/client/rhp.go @@ -8,8 +8,6 @@ import ( "go.sia.tech/core/types" "go.sia.tech/renterd/api" - - rhpv2 "go.sia.tech/core/rhp/v2" ) // RHPBroadcast broadcasts the latest revision for a contract. @@ -24,21 +22,6 @@ func (c *Client) RHPContractRoots(ctx context.Context, contractID types.FileCont return } -// RHPForm forms a contract with a host. -func (c *Client) RHPForm(ctx context.Context, endHeight uint64, hostKey types.PublicKey, hostIP string, renterAddress types.Address, renterFunds types.Currency, hostCollateral types.Currency) (rhpv2.ContractRevision, []types.Transaction, error) { - req := api.RHPFormRequest{ - EndHeight: endHeight, - HostCollateral: hostCollateral, - HostKey: hostKey, - HostIP: hostIP, - RenterFunds: renterFunds, - RenterAddress: renterAddress, - } - var resp api.RHPFormResponse - err := c.c.WithContext(ctx).POST("/rhp/form", req, &resp) - return resp.Contract, resp.TransactionSet, err -} - // RHPFund funds an ephemeral account using the supplied contract. func (c *Client) RHPFund(ctx context.Context, contractID types.FileContractID, hostKey types.PublicKey, hostIP, siamuxAddr string, balance types.Currency) (err error) { req := api.RHPFundRequest{ diff --git a/worker/mocks_test.go b/worker/mocks_test.go index f982437a7..13e5fd733 100644 --- a/worker/mocks_test.go +++ b/worker/mocks_test.go @@ -722,10 +722,6 @@ func (*walletMock) WalletFund(context.Context, *types.Transaction, types.Currenc return nil, nil, nil } -func (*walletMock) WalletPrepareForm(context.Context, types.Address, types.PublicKey, types.Currency, types.Currency, types.PublicKey, rhpv2.HostSettings, uint64) ([]types.Transaction, error) { - return nil, nil -} - func (*walletMock) WalletPrepareRenew(context.Context, types.FileContractRevision, types.Address, types.Address, types.PrivateKey, types.Currency, types.Currency, types.Currency, rhpv3.HostPriceTable, uint64, uint64, uint64) (api.WalletPrepareRenewResponse, error) { return api.WalletPrepareRenewResponse{}, nil } diff --git a/worker/worker.go b/worker/worker.go index 604c66f68..9db14e0e9 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -155,7 +155,6 @@ type ( Wallet interface { WalletDiscard(ctx context.Context, txn types.Transaction) error WalletFund(ctx context.Context, txn *types.Transaction, amount types.Currency, useUnconfirmedTxns bool) ([]types.Hash256, []types.Transaction, error) - WalletPrepareForm(ctx context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) (txns []types.Transaction, err error) WalletPrepareRenew(ctx context.Context, revision types.FileContractRevision, hostAddress, renterAddress types.Address, renterKey types.PrivateKey, renterFunds, minNewCollateral, maxFundAmount types.Currency, pt rhpv3.HostPriceTable, endHeight, windowSize, expectedStorage uint64) (api.WalletPrepareRenewResponse, error) WalletSign(ctx context.Context, txn *types.Transaction, toSign []types.Hash256, cf types.CoveredFields) error } @@ -387,61 +386,6 @@ func (w *Worker) rhpPriceTableHandler(jc jape.Context) { jc.Encode(hpt) } -func (w *Worker) rhpFormHandler(jc jape.Context) { - ctx := jc.Request.Context() - - // decode the request - var rfr api.RHPFormRequest - if jc.Decode(&rfr) != nil { - return - } - - // check renter funds is not zero - if rfr.RenterFunds.IsZero() { - http.Error(jc.ResponseWriter, "RenterFunds can not be zero", http.StatusBadRequest) - return - } - - // apply a pessimistic timeout on contract formations - ctx, cancel := context.WithTimeout(ctx, 15*time.Minute) - defer cancel() - - gp, err := w.bus.GougingParams(ctx) - if jc.Check("could not get gouging parameters", err) != nil { - return - } - gc := newGougingChecker(gp.GougingSettings, gp.ConsensusState, gp.TransactionFee, false) - - hostIP, hostKey, renterFunds := rfr.HostIP, rfr.HostKey, rfr.RenterFunds - renterAddress, endHeight, hostCollateral := rfr.RenterAddress, rfr.EndHeight, rfr.HostCollateral - renterKey := w.deriveRenterKey(hostKey) - - contract, txnSet, err := w.rhp2Client.FormContract(ctx, renterAddress, renterKey, hostKey, hostIP, renterFunds, hostCollateral, endHeight, gc, func(ctx context.Context, renterAddress types.Address, renterKey types.PublicKey, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostSettings rhpv2.HostSettings, endHeight uint64) (txns []types.Transaction, discard func(types.Transaction), err error) { - txns, err = w.bus.WalletPrepareForm(ctx, renterAddress, renterKey, renterFunds, hostCollateral, hostKey, hostSettings, endHeight) - if err != nil { - return nil, nil, err - } - return txns, func(txn types.Transaction) { - _ = w.bus.WalletDiscard(ctx, txn) - }, nil - }) - if jc.Check("couldn't form contract", err) != nil { - return - } - - // broadcast the transaction set - err = w.bus.BroadcastTransaction(ctx, txnSet) - if err != nil { - w.logger.Errorf("failed to broadcast formation txn set: %v", err) - } - - jc.Encode(api.RHPFormResponse{ - ContractID: contract.ID(), - Contract: contract, - TransactionSet: txnSet, - }) -} - func (w *Worker) rhpBroadcastHandler(jc jape.Context) { ctx := jc.Request.Context() @@ -1333,7 +1277,6 @@ func (w *Worker) Handler() http.Handler { "POST /rhp/contract/:id/prune": w.rhpPruneContractHandlerPOST, "GET /rhp/contract/:id/roots": w.rhpContractRootsHandlerGET, "POST /rhp/scan": w.rhpScanHandler, - "POST /rhp/form": w.rhpFormHandler, "POST /rhp/renew": w.rhpRenewHandler, "POST /rhp/fund": w.rhpFundHandler, "POST /rhp/sync": w.rhpSyncHandler, From feb1db2b504f3835f35261af3003460b8e004daf Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 21 Aug 2024 13:43:52 +0200 Subject: [PATCH 04/41] bus: update form contract route --- api/contract.go | 10 ++++++++ api/worker.go | 10 -------- bus/bus.go | 3 +-- bus/client/contracts.go | 2 +- bus/routes.go | 51 ++++++++++++++++++++++------------------- 5 files changed, 39 insertions(+), 37 deletions(-) diff --git a/api/contract.go b/api/contract.go index b7d43b6a7..b012582e2 100644 --- a/api/contract.go +++ b/api/contract.go @@ -144,6 +144,16 @@ type ( TotalCost types.Currency `json:"totalCost"` } + // ContractFormRequest is the request type for the POST /contracts endpoint. + ContractFormRequest struct { + EndHeight uint64 `json:"endHeight"` + HostCollateral types.Currency `json:"hostCollateral"` + HostKey types.PublicKey `json:"hostKey"` + HostIP string `json:"hostIP"` + RenterFunds types.Currency `json:"renterFunds"` + RenterAddress types.Address `json:"renterAddress"` + } + // ContractKeepaliveRequest is the request type for the /contract/:id/keepalive // endpoint. ContractKeepaliveRequest struct { diff --git a/api/worker.go b/api/worker.go index 894fd0c60..9bce3386f 100644 --- a/api/worker.go +++ b/api/worker.go @@ -80,16 +80,6 @@ type ( Error string `json:"error,omitempty"` } - // RHPFormRequest is the request type for the /rhp/form endpoint. - RHPFormRequest struct { - EndHeight uint64 `json:"endHeight"` - HostCollateral types.Currency `json:"hostCollateral"` - HostKey types.PublicKey `json:"hostKey"` - HostIP string `json:"hostIP"` - RenterFunds types.Currency `json:"renterFunds"` - RenterAddress types.Address `json:"renterAddress"` - } - // RHPFormResponse is the response type for the /rhp/form endpoint. RHPFormResponse struct { ContractID types.FileContractID `json:"contractID"` diff --git a/bus/bus.go b/bus/bus.go index 7c61f9144..4bc3092fe 100644 --- a/bus/bus.go +++ b/bus/bus.go @@ -420,6 +420,7 @@ func (b *Bus) Handler() http.Handler { "GET /consensus/siafundfee/:payout": b.contractTaxHandlerGET, "GET /consensus/state": b.consensusStateHandler, + "POST /contracts": b.contractsFormHandler, "GET /contracts": b.contractsHandlerGET, "DELETE /contracts/all": b.contractsAllHandlerDELETE, "POST /contracts/archive": b.contractsArchiveHandlerPOST, @@ -478,8 +479,6 @@ func (b *Bus) Handler() http.Handler { "POST /slabbuffer/done": b.packedSlabsHandlerDonePOST, "POST /slabbuffer/fetch": b.packedSlabsHandlerFetchPOST, - "POST /rhp/form": b.rhpFormHandler, - "POST /search/hosts": b.searchHostsHandlerPOST, "GET /search/objects": b.searchObjectsHandlerGET, diff --git a/bus/client/contracts.go b/bus/client/contracts.go index d1c2d8006..57245afd3 100644 --- a/bus/client/contracts.go +++ b/bus/client/contracts.go @@ -120,7 +120,7 @@ func (c *Client) DeleteContractSet(ctx context.Context, set string) (err error) // FormContract forms a contract with a host and adds it to the bus. func (c *Client) FormContract(ctx context.Context, renterAddress types.Address, renterFunds types.Currency, hostKey types.PublicKey, hostIP string, hostCollateral types.Currency, endHeight uint64) (contract api.ContractMetadata, err error) { - err = c.c.WithContext(ctx).POST("/rhp/form", api.RHPFormRequest{ + err = c.c.WithContext(ctx).POST("/contracts", api.ContractFormRequest{ EndHeight: endHeight, HostCollateral: hostCollateral, HostKey: hostKey, diff --git a/bus/routes.go b/bus/routes.go index ef882710d..6992c010a 100644 --- a/bus/routes.go +++ b/bus/routes.go @@ -2250,13 +2250,13 @@ func (b *Bus) multipartHandlerListPartsPOST(jc jape.Context) { jc.Encode(resp) } -func (b *Bus) rhpFormHandler(jc jape.Context) { +func (b *Bus) contractsFormHandler(jc jape.Context) { // apply pessimistic timeout ctx, cancel := context.WithTimeout(jc.Request.Context(), 15*time.Minute) defer cancel() // decode the request - var rfr api.RHPFormRequest + var rfr api.ContractFormRequest if jc.Decode(&rfr) != nil { return } @@ -2303,12 +2303,11 @@ func (b *Bus) rhpFormHandler(jc jape.Context) { } // send V2 transaction if we're passed the V2 hardfork allow height - var revision rhpv2.ContractRevision + var contract rhpv2.ContractRevision if b.isPassedV2AllowHeight() { panic("not implemented") } else { - var txnSet []types.Transaction - revision, txnSet, err = b.formContract( + contract, err = b.formContract( ctx, settings, rfr.RenterAddress, @@ -2319,26 +2318,15 @@ func (b *Bus) rhpFormHandler(jc jape.Context) { rfr.EndHeight, ) if jc.Check("couldn't form contract", err) != nil { - b.w.ReleaseInputs(txnSet, nil) return } - - // add transaction set to the pool - _, err := b.cm.AddPoolTransactions(txnSet) - if jc.Check("couldn't broadcast transaction set", err) != nil { - b.w.ReleaseInputs(txnSet, nil) - return - } - - // broadcast the transaction set - b.s.BroadcastTransactionSet(txnSet) } // store the contract - contract, err := b.ms.AddContract( + metadata, err := b.ms.AddContract( ctx, - revision, - revision.Revision.MissedHostPayout().Sub(rfr.HostCollateral), + contract, + contract.Revision.MissedHostPayout().Sub(rfr.HostCollateral), rfr.RenterFunds, b.cm.Tip().Height, api.ContractStatePending, @@ -2348,10 +2336,10 @@ func (b *Bus) rhpFormHandler(jc jape.Context) { } // return the contract - jc.Encode(contract) + jc.Encode(metadata) } -func (b *Bus) formContract(ctx context.Context, hostSettings rhpv2.HostSettings, renterAddress types.Address, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostIP string, endHeight uint64) (rhpv2.ContractRevision, []types.Transaction, error) { +func (b *Bus) formContract(ctx context.Context, hostSettings rhpv2.HostSettings, renterAddress types.Address, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostIP string, endHeight uint64) (rhpv2.ContractRevision, error) { // derive the renter key renterKey := b.deriveRenterKey(hostKey) @@ -2368,15 +2356,30 @@ func (b *Bus) formContract(ctx context.Context, hostSettings rhpv2.HostSettings, cost := rhpv2.ContractFormationCost(cs, fc, hostSettings.ContractPrice).Add(fee) toSign, err := b.w.FundTransaction(&txn, cost, true) if err != nil { - return rhpv2.ContractRevision{}, nil, fmt.Errorf("couldn't fund transaction: %w", err) + return rhpv2.ContractRevision{}, fmt.Errorf("couldn't fund transaction: %w", err) } // sign the transaction b.w.SignTransaction(&txn, toSign, wallet.ExplicitCoveredFields(txn)) // form the contract - txnSet := append(b.cm.UnconfirmedParents(txn), txn) - return b.rhp2.FormContract(ctx, hostKey, hostIP, renterKey, txnSet) + contract, txnSet, err := b.rhp2.FormContract(ctx, hostKey, hostIP, renterKey, append(b.cm.UnconfirmedParents(txn), txn)) + if err != nil { + b.w.ReleaseInputs([]types.Transaction{txn}, nil) + return rhpv2.ContractRevision{}, err + } + + // add transaction set to the pool + _, err = b.cm.AddPoolTransactions(txnSet) + if err != nil { + b.w.ReleaseInputs([]types.Transaction{txn}, nil) + return rhpv2.ContractRevision{}, fmt.Errorf("couldn't add transaction set to the pool: %w", err) + } + + // broadcast the transaction set + go b.s.BroadcastTransactionSet(txnSet) + + return contract, nil } func (b *Bus) isPassedV2AllowHeight() bool { From 3082ac92d863decc43bef525714363c2c1964933 Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 21 Aug 2024 13:46:33 +0200 Subject: [PATCH 05/41] bus: re-add AddContract to bus client --- bus/client/contracts.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bus/client/contracts.go b/bus/client/contracts.go index 57245afd3..bb3b16b4c 100644 --- a/bus/client/contracts.go +++ b/bus/client/contracts.go @@ -11,6 +11,18 @@ import ( "go.sia.tech/renterd/api" ) +// AddContract adds the provided contract to the metadata store. +func (c *Client) AddContract(ctx context.Context, contract rhpv2.ContractRevision, contractPrice, totalCost types.Currency, startHeight uint64, state string) (added api.ContractMetadata, err error) { + err = c.c.WithContext(ctx).POST(fmt.Sprintf("/contract/%s", contract.ID()), api.ContractAddRequest{ + Contract: contract, + StartHeight: startHeight, + ContractPrice: contractPrice, + State: state, + TotalCost: totalCost, + }, &added) + return +} + // AddRenewedContract adds the provided contract to the metadata store. func (c *Client) AddRenewedContract(ctx context.Context, contract rhpv2.ContractRevision, contractPrice, totalCost types.Currency, startHeight uint64, renewedFrom types.FileContractID, state string) (renewed api.ContractMetadata, err error) { err = c.c.WithContext(ctx).POST(fmt.Sprintf("/contract/%s/renewed", contract.ID()), api.ContractRenewedRequest{ From 05ff89f40ae51eab54f03822435286e0d471e536 Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 21 Aug 2024 13:51:11 +0200 Subject: [PATCH 06/41] testing: move and rename TestRHPForm --- internal/test/e2e/{rhp_test.go => contracts_test.go} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename internal/test/e2e/{rhp_test.go => contracts_test.go} (98%) diff --git a/internal/test/e2e/rhp_test.go b/internal/test/e2e/contracts_test.go similarity index 98% rename from internal/test/e2e/rhp_test.go rename to internal/test/e2e/contracts_test.go index 08f97b5e1..fcafdd2ac 100644 --- a/internal/test/e2e/rhp_test.go +++ b/internal/test/e2e/contracts_test.go @@ -12,7 +12,7 @@ import ( "go.uber.org/zap/zapcore" ) -func TestRHPForm(t *testing.T) { +func TestFormContract(t *testing.T) { // configure the autopilot not to form any contracts apSettings := test.AutopilotConfig apSettings.Contracts.Amount = 0 From 1a36a8b295e3b153ca0b1fbefcd9b76fd119ec15 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 10:42:42 +0200 Subject: [PATCH 07/41] bus: make sure formed contracts are added to the worker cache --- bus/bus.go | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++ bus/routes.go | 62 ++---------------------------------------------- 2 files changed, 67 insertions(+), 60 deletions(-) diff --git a/bus/bus.go b/bus/bus.go index c4deb1c98..c5ae1113e 100644 --- a/bus/bus.go +++ b/bus/bus.go @@ -542,6 +542,71 @@ func (b *Bus) Shutdown(ctx context.Context) error { ) } +func (b *Bus) addContract(ctx context.Context, rev rhpv2.ContractRevision, contractPrice, totalCost types.Currency, startHeight uint64, state string) (api.ContractMetadata, error) { + c, err := b.ms.AddContract(ctx, rev, contractPrice, totalCost, startHeight, state) + if err != nil { + return api.ContractMetadata{}, err + } + + b.broadcastAction(webhooks.Event{ + Module: api.ModuleContract, + Event: api.EventAdd, + Payload: api.EventContractAdd{ + Added: c, + Timestamp: time.Now().UTC(), + }, + }) + return c, nil +} + +func (b *Bus) isPassedV2AllowHeight() bool { + cs := b.cm.TipState() + return cs.Index.Height >= cs.Network.HardforkV2.AllowHeight +} + +func (b *Bus) formContract(ctx context.Context, hostSettings rhpv2.HostSettings, renterAddress types.Address, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostIP string, endHeight uint64) (rhpv2.ContractRevision, error) { + // derive the renter key + renterKey := b.deriveRenterKey(hostKey) + + // prepare the transaction + cs := b.cm.TipState() + fc := rhpv2.PrepareContractFormation(renterKey.PublicKey(), hostKey, renterFunds, hostCollateral, endHeight, hostSettings, renterAddress) + txn := types.Transaction{FileContracts: []types.FileContract{fc}} + + // calculate the miner fee + fee := b.cm.RecommendedFee().Mul64(cs.TransactionWeight(txn)) + txn.MinerFees = []types.Currency{fee} + + // fund the transaction + cost := rhpv2.ContractFormationCost(cs, fc, hostSettings.ContractPrice).Add(fee) + toSign, err := b.w.FundTransaction(&txn, cost, true) + if err != nil { + return rhpv2.ContractRevision{}, fmt.Errorf("couldn't fund transaction: %w", err) + } + + // sign the transaction + b.w.SignTransaction(&txn, toSign, wallet.ExplicitCoveredFields(txn)) + + // form the contract + contract, txnSet, err := b.rhp2.FormContract(ctx, hostKey, hostIP, renterKey, append(b.cm.UnconfirmedParents(txn), txn)) + if err != nil { + b.w.ReleaseInputs([]types.Transaction{txn}, nil) + return rhpv2.ContractRevision{}, err + } + + // add transaction set to the pool + _, err = b.cm.AddPoolTransactions(txnSet) + if err != nil { + b.w.ReleaseInputs([]types.Transaction{txn}, nil) + return rhpv2.ContractRevision{}, fmt.Errorf("couldn't add transaction set to the pool: %w", err) + } + + // broadcast the transaction set + go b.s.BroadcastTransactionSet(txnSet) + + return contract, nil +} + // initSettings loads the default settings if the setting is not already set and // ensures the settings are valid func (b *Bus) initSettings(ctx context.Context) error { diff --git a/bus/routes.go b/bus/routes.go index a76d862e5..f020c5944 100644 --- a/bus/routes.go +++ b/bus/routes.go @@ -948,20 +948,10 @@ func (b *Bus) contractIDHandlerPOST(jc jape.Context) { return } - a, err := b.ms.AddContract(jc.Request.Context(), req.Contract, req.ContractPrice, req.TotalCost, req.StartHeight, req.State) + a, err := b.addContract(jc.Request.Context(), req.Contract, req.ContractPrice, req.TotalCost, req.StartHeight, req.State) if jc.Check("couldn't store contract", err) != nil { return } - - b.broadcastAction(webhooks.Event{ - Module: api.ModuleContract, - Event: api.EventAdd, - Payload: api.EventContractAdd{ - Added: a, - Timestamp: time.Now().UTC(), - }, - }) - jc.Encode(a) } @@ -2357,7 +2347,7 @@ func (b *Bus) contractsFormHandler(jc jape.Context) { } // store the contract - metadata, err := b.ms.AddContract( + metadata, err := b.addContract( ctx, contract, contract.Revision.MissedHostPayout().Sub(rfr.HostCollateral), @@ -2372,51 +2362,3 @@ func (b *Bus) contractsFormHandler(jc jape.Context) { // return the contract jc.Encode(metadata) } - -func (b *Bus) formContract(ctx context.Context, hostSettings rhpv2.HostSettings, renterAddress types.Address, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostIP string, endHeight uint64) (rhpv2.ContractRevision, error) { - // derive the renter key - renterKey := b.deriveRenterKey(hostKey) - - // prepare the transaction - cs := b.cm.TipState() - fc := rhpv2.PrepareContractFormation(renterKey.PublicKey(), hostKey, renterFunds, hostCollateral, endHeight, hostSettings, renterAddress) - txn := types.Transaction{FileContracts: []types.FileContract{fc}} - - // calculate the miner fee - fee := b.cm.RecommendedFee().Mul64(cs.TransactionWeight(txn)) - txn.MinerFees = []types.Currency{fee} - - // fund the transaction - cost := rhpv2.ContractFormationCost(cs, fc, hostSettings.ContractPrice).Add(fee) - toSign, err := b.w.FundTransaction(&txn, cost, true) - if err != nil { - return rhpv2.ContractRevision{}, fmt.Errorf("couldn't fund transaction: %w", err) - } - - // sign the transaction - b.w.SignTransaction(&txn, toSign, wallet.ExplicitCoveredFields(txn)) - - // form the contract - contract, txnSet, err := b.rhp2.FormContract(ctx, hostKey, hostIP, renterKey, append(b.cm.UnconfirmedParents(txn), txn)) - if err != nil { - b.w.ReleaseInputs([]types.Transaction{txn}, nil) - return rhpv2.ContractRevision{}, err - } - - // add transaction set to the pool - _, err = b.cm.AddPoolTransactions(txnSet) - if err != nil { - b.w.ReleaseInputs([]types.Transaction{txn}, nil) - return rhpv2.ContractRevision{}, fmt.Errorf("couldn't add transaction set to the pool: %w", err) - } - - // broadcast the transaction set - go b.s.BroadcastTransactionSet(txnSet) - - return contract, nil -} - -func (b *Bus) isPassedV2AllowHeight() bool { - cs := b.cm.TipState() - return cs.Index.Height >= cs.Network.HardforkV2.AllowHeight -} From 7638e49acedc376b94bcf24f7f7c8abf729e74eb Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 10:54:04 +0200 Subject: [PATCH 08/41] worker: AccountMgr --- api/bus.go | 10 + autopilot/accounts.go | 291 --------------- autopilot/alerts.go | 23 -- autopilot/autopilot.go | 7 +- bus/bus.go | 57 +-- bus/client/accounts.go | 18 +- bus/routes.go | 13 + internal/bus/accounts.go | 16 +- internal/bus/accounts_test.go | 6 +- internal/bus/pinmanager.go | 58 ++- internal/test/e2e/cluster.go | 11 +- internal/test/e2e/cluster_test.go | 27 +- internal/worker/accounts.go | 589 ++++++++++++++++++++++++++++++ stores/accounts.go | 6 +- worker/accounts.go | 167 --------- worker/client/client.go | 6 + worker/host.go | 39 +- worker/mocks_test.go | 26 +- worker/worker.go | 134 ++++--- 19 files changed, 822 insertions(+), 682 deletions(-) delete mode 100644 autopilot/accounts.go create mode 100644 internal/worker/accounts.go delete mode 100644 worker/accounts.go diff --git a/api/bus.go b/api/bus.go index 453af61ca..a124f50e5 100644 --- a/api/bus.go +++ b/api/bus.go @@ -45,6 +45,16 @@ type ( ) type ( + AccountsSaveRequest struct { + Owner string `json:"owner"` + Accounts []Account `json:"accounts"` + SetUnclean bool `json:"setUnclean"` + } + + AccountsUncleanRequest struct { + Owner string `json:"owner"` + } + // BusStateResponse is the response type for the /bus/state endpoint. BusStateResponse struct { StartTime TimeRFC3339 `json:"startTime"` diff --git a/autopilot/accounts.go b/autopilot/accounts.go deleted file mode 100644 index a1422d69a..000000000 --- a/autopilot/accounts.go +++ /dev/null @@ -1,291 +0,0 @@ -package autopilot - -import ( - "context" - "errors" - "fmt" - "math/big" - "sync" - "time" - - rhpv3 "go.sia.tech/core/rhp/v3" - "go.sia.tech/core/types" - "go.sia.tech/renterd/alerts" - "go.sia.tech/renterd/api" - "go.uber.org/zap" -) - -var errMaxDriftExceeded = errors.New("drift on account is too large") - -var ( - minBalance = types.Siacoins(1).Div64(2).Big() - maxBalance = types.Siacoins(1) - maxNegDrift = new(big.Int).Neg(types.Siacoins(10).Big()) -) - -type accounts struct { - ap *Autopilot - a AccountStore - c ContractStore - l *zap.SugaredLogger - w *workerPool - - refillInterval time.Duration - revisionSubmissionBuffer uint64 - - mu sync.Mutex - inProgressRefills map[types.Hash256]struct{} -} - -type AccountStore interface { - Account(ctx context.Context, id rhpv3.Account, hk types.PublicKey) (account api.Account, err error) - Accounts(ctx context.Context) (accounts []api.Account, err error) -} - -type ContractStore interface { - Contracts(ctx context.Context, opts api.ContractsOpts) ([]api.ContractMetadata, error) -} - -func newAccounts(ap *Autopilot, a AccountStore, c ContractStore, w *workerPool, l *zap.SugaredLogger, refillInterval time.Duration, revisionSubmissionBuffer uint64) *accounts { - return &accounts{ - ap: ap, - a: a, - c: c, - l: l.Named("accounts"), - w: w, - - refillInterval: refillInterval, - revisionSubmissionBuffer: revisionSubmissionBuffer, - inProgressRefills: make(map[types.Hash256]struct{}), - } -} - -func (a *accounts) markRefillInProgress(workerID string, hk types.PublicKey) bool { - a.mu.Lock() - defer a.mu.Unlock() - k := types.HashBytes(append([]byte(workerID), hk[:]...)) - _, inProgress := a.inProgressRefills[k] - if inProgress { - return false - } - a.inProgressRefills[k] = struct{}{} - return true -} - -func (a *accounts) markRefillDone(workerID string, hk types.PublicKey) { - a.mu.Lock() - defer a.mu.Unlock() - k := types.HashBytes(append([]byte(workerID), hk[:]...)) - _, inProgress := a.inProgressRefills[k] - if !inProgress { - panic("releasing a refill that hasn't been in progress") - } - delete(a.inProgressRefills, k) -} - -func (a *accounts) refillWorkersAccountsLoop(ctx context.Context) { - ticker := time.NewTicker(a.refillInterval) - - for { - select { - case <-ctx.Done(): - return // shutdown - case <-ticker.C: - } - - a.w.withWorker(func(w Worker) { - a.refillWorkerAccounts(ctx, w) - }) - } -} - -// refillWorkerAccounts refills all accounts on a worker that require a refill. -// To avoid slow hosts preventing refills for fast hosts, a separate goroutine -// is used for every host. If a slow host's account is still being refilled by a -// goroutine from a previous call, refillWorkerAccounts will skip that account -// until the previously launched goroutine returns. -func (a *accounts) refillWorkerAccounts(ctx context.Context, w Worker) { - // fetch config - cfg, err := a.ap.Config(ctx) - if err != nil { - a.l.Errorw(fmt.Sprintf("failed to fetch config for refill: %v", err)) - return - } - - // fetch consensus state - cs, err := a.ap.bus.ConsensusState(ctx) - if err != nil { - a.l.Errorw(fmt.Sprintf("failed to fetch consensus state for refill: %v", err)) - return - } - - // fetch worker id - workerID, err := w.ID(ctx) - if err != nil { - a.l.Errorw(fmt.Sprintf("failed to fetch worker id for refill: %v", err)) - return - } - - // fetch all contracts - contracts, err := a.c.Contracts(ctx, api.ContractsOpts{}) - if err != nil { - a.l.Errorw(fmt.Sprintf("failed to fetch contracts for refill: %v", err)) - return - } else if len(contracts) == 0 { - return - } - - // filter all contract set contracts - var contractSetContracts []api.ContractMetadata - inContractSet := make(map[types.FileContractID]struct{}) - for _, c := range contracts { - if c.InSet(cfg.Config.Contracts.Set) { - contractSetContracts = append(contractSetContracts, c) - inContractSet[c.ID] = struct{}{} - } - } - - // refill accounts in separate goroutines - for _, c := range contracts { - // launch refill if not already in progress - if a.markRefillInProgress(workerID, c.HostKey) { - go func(contract api.ContractMetadata) { - defer a.markRefillDone(workerID, contract.HostKey) - - rCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) - defer cancel() - accountID, refilled, rerr := refillWorkerAccount(rCtx, a.a, w, contract, cs.BlockHeight, a.revisionSubmissionBuffer) - if rerr != nil { - if rerr.Is(errMaxDriftExceeded) { - // register the alert if error is errMaxDriftExceeded - a.ap.RegisterAlert(ctx, newAccountRefillAlert(accountID, contract, *rerr)) - } - if _, inSet := inContractSet[contract.ID]; inSet { - a.l.Errorw(rerr.err.Error(), rerr.keysAndValues...) - } else { - a.l.Debugw(rerr.err.Error(), rerr.keysAndValues...) - } - } else { - // dismiss alerts on success - a.ap.DismissAlert(ctx, alerts.IDForAccount(alertAccountRefillID, accountID)) - - // log success - if refilled { - a.l.Infow("Successfully funded account", - "account", accountID, - "host", contract.HostKey, - "balance", maxBalance, - ) - } - } - }(c) - } - } -} - -type refillError struct { - err error - keysAndValues []interface{} -} - -func (err *refillError) Error() string { - if err.err == nil { - return "" - } - return err.err.Error() -} - -func (err *refillError) Is(target error) bool { - return errors.Is(err.err, target) -} - -func refillWorkerAccount(ctx context.Context, a AccountStore, w Worker, contract api.ContractMetadata, bh, revisionSubmissionBuffer uint64) (accountID rhpv3.Account, refilled bool, rerr *refillError) { - wrapErr := func(err error, keysAndValues ...interface{}) *refillError { - if err == nil { - return nil - } - return &refillError{ - err: err, - keysAndValues: keysAndValues, - } - } - - // fetch the account - accountID, err := w.Account(ctx, contract.HostKey) - if err != nil { - rerr = wrapErr(err) - return - } - var account api.Account - account, err = a.Account(ctx, accountID, contract.HostKey) - if err != nil { - rerr = wrapErr(err) - return - } - - // check if the contract is too close to the proof window to be revised, - // trying to refill the account would result in the host not returning the - // revision and returning an obfuscated error - if (bh + revisionSubmissionBuffer) > contract.WindowStart { - rerr = wrapErr(fmt.Errorf("not refilling account since contract is too close to the proof window to be revised (%v > %v)", bh+revisionSubmissionBuffer, contract.WindowStart), - "accountID", account.ID, - "hostKey", contract.HostKey, - "blockHeight", bh, - ) - return - } - - // check if a host is potentially cheating before refilling. - // We only check against the max drift if the account's drift is - // negative because we don't care if we have more money than - // expected. - if account.Drift.Cmp(maxNegDrift) < 0 { - rerr = wrapErr(fmt.Errorf("not refilling account since host is potentially cheating: %w", errMaxDriftExceeded), - "accountID", account.ID, - "hostKey", contract.HostKey, - "balance", account.Balance, - "drift", account.Drift, - ) - return - } - - // check if a resync is needed - if account.RequiresSync { - // sync the account - err = w.RHPSync(ctx, contract.ID, contract.HostKey, contract.HostIP, contract.SiamuxAddr) - if err != nil { - rerr = wrapErr(fmt.Errorf("failed to sync account's balance: %w", err), - "accountID", account.ID, - "hostKey", contract.HostKey, - ) - return - } - - // refetch the account after syncing - account, err = a.Account(ctx, accountID, contract.HostKey) - if err != nil { - rerr = wrapErr(err) - return - } - } - - // check if refill is needed - if account.Balance.Cmp(minBalance) >= 0 { - rerr = wrapErr(err) - return - } - - // fund the account - err = w.RHPFund(ctx, contract.ID, contract.HostKey, contract.HostIP, contract.SiamuxAddr, maxBalance) - if err != nil { - rerr = wrapErr(fmt.Errorf("failed to fund account: %w", err), - "accountID", account.ID, - "hostKey", contract.HostKey, - "balance", account.Balance, - "expected", maxBalance, - ) - } else { - refilled = true - } - return -} diff --git a/autopilot/alerts.go b/autopilot/alerts.go index 1d089c39d..47a926ad5 100644 --- a/autopilot/alerts.go +++ b/autopilot/alerts.go @@ -5,15 +5,12 @@ import ( "fmt" "time" - rhpv3 "go.sia.tech/core/rhp/v3" "go.sia.tech/core/types" "go.sia.tech/renterd/alerts" - "go.sia.tech/renterd/api" "go.sia.tech/renterd/object" ) var ( - alertAccountRefillID = alerts.RandomAlertID() // constant until restarted alertHealthRefreshID = alerts.RandomAlertID() // constant until restarted alertLowBalanceID = alerts.RandomAlertID() // constant until restarted alertMigrationID = alerts.RandomAlertID() // constant until restarted @@ -54,26 +51,6 @@ func newAccountLowBalanceAlert(address types.Address, balance, allowance types.C } } -func newAccountRefillAlert(id rhpv3.Account, contract api.ContractMetadata, err refillError) alerts.Alert { - data := map[string]interface{}{ - "error": err.Error(), - "accountID": id.String(), - "contractID": contract.ID.String(), - "hostKey": contract.HostKey.String(), - } - for i := 0; i < len(err.keysAndValues); i += 2 { - data[fmt.Sprint(err.keysAndValues[i])] = err.keysAndValues[i+1] - } - - return alerts.Alert{ - ID: alerts.IDForAccount(alertAccountRefillID, id), - Severity: alerts.SeverityError, - Message: "Ephemeral account refill failed", - Data: data, - Timestamp: time.Now(), - } -} - func newContractPruningFailedAlert(hk types.PublicKey, version, release string, fcid types.FileContractID, err error) alerts.Alert { return alerts.Alert{ ID: alerts.IDForContract(alertPruningID, fcid), diff --git a/autopilot/autopilot.go b/autopilot/autopilot.go index 9ea235a11..694006085 100644 --- a/autopilot/autopilot.go +++ b/autopilot/autopilot.go @@ -11,7 +11,6 @@ import ( "time" rhpv2 "go.sia.tech/core/rhp/v2" - rhpv3 "go.sia.tech/core/rhp/v3" "go.sia.tech/core/types" "go.sia.tech/jape" "go.sia.tech/renterd/alerts" @@ -31,8 +30,7 @@ type Bus interface { webhooks.Broadcaster // Accounts - Account(ctx context.Context, id rhpv3.Account, hostKey types.PublicKey) (account api.Account, err error) - Accounts(ctx context.Context) (accounts []api.Account, err error) + Accounts(ctx context.Context, owner string) (accounts []api.Account, err error) // Autopilots Autopilot(ctx context.Context, id string) (autopilot api.Autopilot, err error) @@ -100,7 +98,6 @@ type Autopilot struct { logger *zap.SugaredLogger workers *workerPool - a *accounts c *contractor.Contractor m *migrator s scanner.Scanner @@ -149,7 +146,6 @@ func New(cfg config.Autopilot, bus Bus, workers []Worker, logger *zap.Logger) (_ ap.c = contractor.New(bus, bus, ap.logger, cfg.RevisionSubmissionBuffer, cfg.RevisionBroadcastInterval) ap.m = newMigrator(ap, cfg.MigrationHealthCutoff, cfg.MigratorParallelSlabsPerWorker) - ap.a = newAccounts(ap, ap.bus, ap.bus, ap.workers, ap.logger, cfg.AccountsRefillInterval, cfg.RevisionSubmissionBuffer) return ap, nil } @@ -327,7 +323,6 @@ func (ap *Autopilot) Run() { if maintenanceSuccess { launchAccountRefillsOnce.Do(func() { ap.logger.Info("account refills loop launched") - go ap.a.refillWorkersAccountsLoop(ap.shutdownCtx) }) } diff --git a/bus/bus.go b/bus/bus.go index c5ae1113e..accf9f34c 100644 --- a/bus/bus.go +++ b/bus/bus.go @@ -178,9 +178,9 @@ type ( // are rapidly updated and can be recovered, they are only loaded upon // startup and persisted upon shutdown. AccountStore interface { - Accounts(context.Context) ([]api.Account, error) - SaveAccounts(context.Context, []api.Account) error - SetUncleanShutdown(context.Context) error + Accounts(context.Context, string) ([]api.Account, error) + SaveAccounts(context.Context, string, []api.Account) error + SetUncleanShutdown(context.Context, string) error } // An AutopilotStore stores autopilots. @@ -309,15 +309,16 @@ type Bus struct { startTime time.Time masterKey [32]byte - accountsMgr AccountManager - alerts alerts.Alerter - alertMgr AlertManager - pinMgr PinManager - webhooksMgr WebhooksManager - cm ChainManager - cs ChainSubscriber - s Syncer - w Wallet + accountsMgr AccountManager + alerts alerts.Alerter + alertMgr AlertManager + pinMgr PinManager + webhooksMgr WebhooksManager + accountStore AccountStore + cm ChainManager + cs ChainSubscriber + s Syncer + w Wallet as AutopilotStore hs HostStore @@ -342,14 +343,15 @@ func New(ctx context.Context, masterKey [32]byte, am AlertManager, wm WebhooksMa startTime: time.Now(), masterKey: masterKey, - s: s, - cm: cm, - w: w, - hs: store, - as: store, - ms: store, - mtrcs: store, - ss: store, + accountStore: store, + s: s, + cm: cm, + w: w, + hs: store, + as: store, + ms: store, + mtrcs: store, + ss: store, alerts: alerts.WithOrigin(am, "bus"), alertMgr: am, @@ -392,13 +394,14 @@ func New(ctx context.Context, masterKey [32]byte, am AlertManager, wm WebhooksMa func (b *Bus) Handler() http.Handler { return jape.Mux(map[string]jape.Handler{ "GET /accounts": b.accountsHandlerGET, - "POST /account/:id": b.accountHandlerGET, - "POST /account/:id/add": b.accountsAddHandlerPOST, - "POST /account/:id/lock": b.accountsLockHandlerPOST, - "POST /account/:id/unlock": b.accountsUnlockHandlerPOST, - "POST /account/:id/update": b.accountsUpdateHandlerPOST, - "POST /account/:id/requiressync": b.accountsRequiresSyncHandlerPOST, - "POST /account/:id/resetdrift": b.accountsResetDriftHandlerPOST, + "POST /accounts": b.accountsHandlerPOST, + "POST /account/:id": b.accountHandlerGET, // deprecated + "POST /account/:id/add": b.accountsAddHandlerPOST, // deprecated + "POST /account/:id/lock": b.accountsLockHandlerPOST, // deprecated + "POST /account/:id/unlock": b.accountsUnlockHandlerPOST, // deprecated + "POST /account/:id/update": b.accountsUpdateHandlerPOST, // deprecated + "POST /account/:id/requiressync": b.accountsRequiresSyncHandlerPOST, // deprecated + "POST /account/:id/resetdrift": b.accountsResetDriftHandlerPOST, // deprecated "GET /alerts": b.handleGETAlerts, "POST /alerts/dismiss": b.handlePOSTAlertsDismiss, diff --git a/bus/client/accounts.go b/bus/client/accounts.go index 052928ae4..467e8d8d7 100644 --- a/bus/client/accounts.go +++ b/bus/client/accounts.go @@ -20,8 +20,8 @@ func (c *Client) Account(ctx context.Context, id rhpv3.Account, hostKey types.Pu } // Accounts returns all accounts. -func (c *Client) Accounts(ctx context.Context) (accounts []api.Account, err error) { - err = c.c.WithContext(ctx).GET("/accounts", &accounts) +func (c *Client) Accounts(ctx context.Context, owner string) (accounts []api.Account, err error) { + err = c.c.WithContext(ctx).GET(fmt.Sprintf("/accounts?owner=%s", owner), &accounts) return } @@ -51,6 +51,16 @@ func (c *Client) ResetDrift(ctx context.Context, id rhpv3.Account) (err error) { return } +// SaveAccounts saves all accounts. +func (c *Client) SaveAccounts(ctx context.Context, owner string, accounts []api.Account, setUnclean bool) (err error) { + err = c.c.WithContext(ctx).POST("/accounts", &api.AccountsSaveRequest{ + Accounts: accounts, + Owner: owner, + SetUnclean: setUnclean, + }, nil) + return +} + // SetBalance sets the given account's balance to a certain amount. func (c *Client) SetBalance(ctx context.Context, id rhpv3.Account, hostKey types.PublicKey, amount *big.Int) (err error) { err = c.c.WithContext(ctx).POST(fmt.Sprintf("/account/%s/update", id), api.AccountsUpdateBalanceRequest{ @@ -60,6 +70,10 @@ func (c *Client) SetBalance(ctx context.Context, id rhpv3.Account, hostKey types return } +func (c *Client) SetUncleanShutdown(context.Context, string) error { + panic("not implemented") +} + // ScheduleSync sets the requiresSync flag of an account. func (c *Client) ScheduleSync(ctx context.Context, id rhpv3.Account, hostKey types.PublicKey) (err error) { err = c.c.WithContext(ctx).POST(fmt.Sprintf("/account/%s/requiressync", id), api.AccountsRequiresSyncRequest{ diff --git a/bus/routes.go b/bus/routes.go index f020c5944..edd293a0d 100644 --- a/bus/routes.go +++ b/bus/routes.go @@ -1714,6 +1714,19 @@ func (b *Bus) accountsHandlerGET(jc jape.Context) { jc.Encode(b.accountsMgr.Accounts()) } +func (b *Bus) accountsHandlerPOST(jc jape.Context) { + var req api.AccountsSaveRequest + if jc.Decode(&req) != nil { + return + } else if b.accountStore.SaveAccounts(jc.Request.Context(), req.Owner, req.Accounts) != nil { + return + } else if !req.SetUnclean { + return + } else if jc.Check("failed to set accounts unclean", b.accountStore.SetUncleanShutdown(jc.Request.Context(), req.Owner)) != nil { + return + } +} + func (b *Bus) accountHandlerGET(jc jape.Context) { var id rhpv3.Account if jc.DecodeParam("id", &id) != nil { diff --git a/internal/bus/accounts.go b/internal/bus/accounts.go index 3586250cb..564bc1d99 100644 --- a/internal/bus/accounts.go +++ b/internal/bus/accounts.go @@ -16,15 +16,19 @@ import ( "lukechampine.com/frand" ) +const ( + busAccountOwner = "bus" +) + var ( ErrAccountNotFound = errors.New("account doesn't exist") ) type ( AccountStore interface { - Accounts(context.Context) ([]api.Account, error) - SaveAccounts(context.Context, []api.Account) error - SetUncleanShutdown(context.Context) error + Accounts(context.Context, string) ([]api.Account, error) + SaveAccounts(context.Context, string, []api.Account) error + SetUncleanShutdown(context.Context, string) error } ) @@ -60,7 +64,7 @@ func NewAccountManager(ctx context.Context, s AccountStore, logger *zap.Logger) logger = logger.Named("accounts") // load saved accounts - saved, err := s.Accounts(ctx) + saved, err := s.Accounts(ctx, busAccountOwner) if err != nil { return nil, err } @@ -76,7 +80,7 @@ func NewAccountManager(ctx context.Context, s AccountStore, logger *zap.Logger) } // mark the shutdown as unclean, this will be overwritten on shutdown - err = s.SetUncleanShutdown(ctx) + err = s.SetUncleanShutdown(ctx, busAccountOwner) if err != nil { return nil, fmt.Errorf("failed to mark account shutdown as unclean: %w", err) } @@ -252,7 +256,7 @@ func (a *AccountMgr) ScheduleSync(id rhpv3.Account, hk types.PublicKey) error { func (a *AccountMgr) Shutdown(ctx context.Context) error { accounts := a.Accounts() - err := a.s.SaveAccounts(ctx, accounts) + err := a.s.SaveAccounts(ctx, busAccountOwner, accounts) if err != nil { a.logger.Errorf("failed to save %v accounts: %v", len(accounts), err) return err diff --git a/internal/bus/accounts_test.go b/internal/bus/accounts_test.go index 38d062e75..55e3a3f95 100644 --- a/internal/bus/accounts_test.go +++ b/internal/bus/accounts_test.go @@ -14,9 +14,9 @@ import ( type mockAccStore struct{} -func (m *mockAccStore) Accounts(context.Context) ([]api.Account, error) { return nil, nil } -func (m *mockAccStore) SaveAccounts(context.Context, []api.Account) error { return nil } -func (m *mockAccStore) SetUncleanShutdown(context.Context) error { return nil } +func (m *mockAccStore) Accounts(context.Context, string) ([]api.Account, error) { return nil, nil } +func (m *mockAccStore) SaveAccounts(context.Context, string, []api.Account) error { return nil } +func (m *mockAccStore) SetUncleanShutdown(context.Context, string) error { return nil } func TestAccountLocking(t *testing.T) { eas := &mockAccStore{} diff --git a/internal/bus/pinmanager.go b/internal/bus/pinmanager.go index c128a8392..32e283812 100644 --- a/internal/bus/pinmanager.go +++ b/internal/bus/pinmanager.go @@ -66,8 +66,11 @@ func NewPinManager(alerts alerts.Alerter, broadcaster webhooks.Broadcaster, s St } // start the pin manager - pm.run() - + pm.wg.Add(1) + go func() { + pm.run() + pm.wg.Done() + }() return pm } @@ -146,35 +149,30 @@ func (pm *pinManager) rateExceedsThreshold(threshold float64) bool { } func (pm *pinManager) run() { - pm.wg.Add(1) - go func() { - defer pm.wg.Done() - - t := time.NewTicker(pm.updateInterval) - defer t.Stop() - - var forced bool - for { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - err := pm.updatePrices(ctx, forced) - if err != nil { - pm.logger.Warn("failed to update prices", zap.Error(err)) - pm.a.RegisterAlert(ctx, newPricePinningFailedAlert(err)) - } else { - pm.a.DismissAlerts(ctx, alertPricePinningID) - } - cancel() - - forced = false - select { - case <-pm.closedChan: - return - case <-pm.triggerChan: - forced = true - case <-t.C: - } + t := time.NewTicker(pm.updateInterval) + defer t.Stop() + + var forced bool + for { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + err := pm.updatePrices(ctx, forced) + if err != nil { + pm.logger.Warn("failed to update prices", zap.Error(err)) + pm.a.RegisterAlert(ctx, newPricePinningFailedAlert(err)) + } else { + pm.a.DismissAlerts(ctx, alertPricePinningID) } - }() + cancel() + + forced = false + select { + case <-pm.closedChan: + return + case <-pm.triggerChan: + forced = true + case <-t.C: + } + } } func (pm *pinManager) updateAutopilotSettings(ctx context.Context, autopilotID string, pins api.AutopilotPins, rate decimal.Decimal) error { diff --git a/internal/test/e2e/cluster.go b/internal/test/e2e/cluster.go index b500643d3..668683a25 100644 --- a/internal/test/e2e/cluster.go +++ b/internal/test/e2e/cluster.go @@ -88,6 +88,13 @@ type dbConfig struct { RetryTxIntervals []time.Duration } +func (tc *TestCluster) Accounts() []api.Account { + tc.tt.Helper() + accounts, err := tc.Worker.Accounts(context.Background()) + tc.tt.OK(err) + return accounts +} + func (tc *TestCluster) ShutdownAutopilot(ctx context.Context) { tc.tt.Helper() for _, fn := range tc.autopilotShutdownFns { @@ -707,7 +714,7 @@ func (c *TestCluster) WaitForAccounts() []api.Account { c.waitForHostAccounts(hostsMap) // fetch all accounts - accounts, err := c.Bus.Accounts(context.Background()) + accounts, err := c.Worker.Accounts(context.Background()) c.tt.OK(err) return accounts } @@ -904,7 +911,7 @@ func (c *TestCluster) Shutdown() { func (c *TestCluster) waitForHostAccounts(hosts map[types.PublicKey]struct{}) { c.tt.Helper() c.tt.Retry(300, 100*time.Millisecond, func() error { - accounts, err := c.Bus.Accounts(context.Background()) + accounts, err := c.Worker.Accounts(context.Background()) if err != nil { return err } diff --git a/internal/test/e2e/cluster_test.go b/internal/test/e2e/cluster_test.go index 582353425..300cda8ef 100644 --- a/internal/test/e2e/cluster_test.go +++ b/internal/test/e2e/cluster_test.go @@ -1158,8 +1158,7 @@ func TestEphemeralAccounts(t *testing.T) { } // Fetch accounts again. - accounts, err := cluster.Bus.Accounts(context.Background()) - tt.OK(err) + accounts = cluster.Accounts() acc := accounts[0] if acc.Balance.Cmp(types.Siacoins(1).Big()) < 0 { @@ -1177,8 +1176,7 @@ func TestEphemeralAccounts(t *testing.T) { } // Fetch account from bus directly. - busAccounts, err := cluster.Bus.Accounts(context.Background()) - tt.OK(err) + busAccounts := cluster.Accounts() if len(busAccounts) != 1 { t.Fatal("expected one account but got", len(busAccounts)) } @@ -1207,8 +1205,7 @@ func TestEphemeralAccounts(t *testing.T) { if err := cluster.Bus.SetBalance(context.Background(), busAcc.ID, acc.HostKey, newBalance.Big()); err != nil { t.Fatal(err) } - busAccounts, err = cluster.Bus.Accounts(context.Background()) - tt.OK(err) + busAccounts = cluster.Accounts() busAcc = busAccounts[0] maxNewDrift := newDrift.Add(newDrift, types.NewCurrency64(2).Big()) // forgive 2H if busAcc.Drift.Cmp(maxNewDrift) > 0 { @@ -1220,8 +1217,7 @@ func TestEphemeralAccounts(t *testing.T) { defer cluster2.Shutdown() // Check that accounts were loaded from the bus. - accounts2, err := cluster2.Bus.Accounts(context.Background()) - tt.OK(err) + accounts2 := cluster2.Accounts() for _, acc := range accounts2 { if acc.Balance.Cmp(big.NewInt(0)) == 0 { t.Fatal("account balance wasn't loaded") @@ -1236,13 +1232,11 @@ func TestEphemeralAccounts(t *testing.T) { if err := cluster2.Bus.ResetDrift(context.Background(), acc.ID); err != nil { t.Fatal(err) } - accounts2, err = cluster2.Bus.Accounts(context.Background()) - tt.OK(err) + accounts2 = cluster2.Accounts() if accounts2[0].Drift.Cmp(new(big.Int)) != 0 { t.Fatal("drift wasn't reset", accounts2[0].Drift.String()) } - accounts2, err = cluster2.Bus.Accounts(context.Background()) - tt.OK(err) + accounts2 = cluster2.Accounts() if accounts2[0].Drift.Cmp(new(big.Int)) != 0 { t.Fatal("drift wasn't reset", accounts2[0].Drift.String()) } @@ -1387,8 +1381,7 @@ func TestEphemeralAccountSync(t *testing.T) { cluster.ShutdownAutopilot(context.Background()) // Fetch the account balance before setting the balance - accounts, err := cluster.Bus.Accounts(context.Background()) - tt.OK(err) + accounts := cluster.Accounts() if len(accounts) != 1 || accounts[0].RequiresSync { t.Fatal("account shouldn't require a sync") } @@ -1401,8 +1394,7 @@ func TestEphemeralAccountSync(t *testing.T) { if err := cluster.Bus.ScheduleSync(context.Background(), acc.ID, acc.HostKey); err != nil { t.Fatal(err) } - accounts, err = cluster.Bus.Accounts(context.Background()) - tt.OK(err) + accounts = cluster.Accounts() if len(accounts) != 1 || !accounts[0].RequiresSync { t.Fatal("account wasn't updated") } @@ -1431,8 +1423,7 @@ func TestEphemeralAccountSync(t *testing.T) { }) // Flag should also be reset on bus now. - accounts, err = cluster2.Bus.Accounts(context.Background()) - tt.OK(err) + accounts = cluster2.Accounts() if len(accounts) != 1 || accounts[0].RequiresSync { t.Fatal("account wasn't updated") } diff --git a/internal/worker/accounts.go b/internal/worker/accounts.go new file mode 100644 index 000000000..3946b0d07 --- /dev/null +++ b/internal/worker/accounts.go @@ -0,0 +1,589 @@ +package worker + +import ( + "context" + "errors" + "fmt" + "math/big" + "sync" + "time" + + rhpv3 "go.sia.tech/core/rhp/v3" + "go.sia.tech/core/types" + "go.sia.tech/renterd/alerts" + "go.sia.tech/renterd/api" + rhp3 "go.sia.tech/renterd/internal/rhp/v3" + "go.uber.org/zap" +) + +var ( + ErrAccountNotFound = errors.New("account doesn't exist") + + errMaxDriftExceeded = errors.New("drift on account is too large") +) + +var ( + minBalance = types.Siacoins(1).Div64(2).Big() + maxBalance = types.Siacoins(1) + maxNegDrift = new(big.Int).Neg(types.Siacoins(10).Big()) + + alertAccountRefillID = alerts.RandomAlertID() // constant until restarted +) + +type ( + AccountMgrWorker interface { + FundAccount(ctx context.Context, fcid types.FileContractID, hk types.PublicKey, siamuxAddr string, balance types.Currency) error + SyncAccount(ctx context.Context, fcid types.FileContractID, hk types.PublicKey, siamuxAddr string) error + } + + AccountStore interface { + Accounts(context.Context, string) ([]api.Account, error) + SaveAccounts(context.Context, string, []api.Account, bool) error + } + + ConsensusState interface { + ConsensusState(ctx context.Context) (api.ConsensusState, error) + } + + DownloadContracts interface { + DownloadContracts(ctx context.Context) ([]api.ContractMetadata, error) + } +) + +type ( + AccountMgr struct { + w AccountMgrWorker + dc DownloadContracts + cs ConsensusState + s AccountStore + key types.PrivateKey + logger *zap.SugaredLogger + owner string + refillInterval time.Duration + revisionSubmissionBuffer uint64 + shutdownCtx context.Context + shutdownCancel context.CancelFunc + wg sync.WaitGroup + + mu sync.Mutex + byID map[rhpv3.Account]*Account + inProgressRefills map[types.PublicKey]struct{} + lastLoggedRefillErr map[types.PublicKey]time.Time + } + + Account struct { + key types.PrivateKey + logger *zap.SugaredLogger + + rwmu sync.RWMutex + + mu sync.Mutex + requiresSyncTime time.Time + acc api.Account + } +) + +// NewAccountManager creates a new account manager. It will load all accounts +// from the given store and mark the shutdown as unclean. When Shutdown is +// called it will save all accounts. +func NewAccountManager(key types.PrivateKey, owner string, w AccountMgrWorker, cs ConsensusState, dc DownloadContracts, s AccountStore, refillInterval time.Duration, l *zap.Logger) (*AccountMgr, error) { + logger := l.Named("accounts").Sugar() + + shutdownCtx, shutdownCancel := context.WithCancel(context.Background()) + a := &AccountMgr{ + w: w, + cs: cs, + dc: dc, + s: s, + key: key, + logger: logger, + owner: owner, + + inProgressRefills: make(map[types.PublicKey]struct{}), + lastLoggedRefillErr: make(map[types.PublicKey]time.Time), + refillInterval: refillInterval, + shutdownCtx: shutdownCtx, + shutdownCancel: shutdownCancel, + + byID: make(map[rhpv3.Account]*Account), + } + a.wg.Add(1) + go func() { + a.run() + a.wg.Done() + }() + return a, nil +} + +// Account returns the account with the given id. +func (a *AccountMgr) Account(hostKey types.PublicKey) api.Account { + acc := a.account(hostKey) + acc.mu.Lock() + defer acc.mu.Unlock() + return acc.acc +} + +// Accounts returns all accounts. +func (a *AccountMgr) Accounts() []api.Account { + a.mu.Lock() + defer a.mu.Unlock() + accounts := make([]api.Account, 0, len(a.byID)) + for _, acc := range a.byID { + acc.mu.Lock() + accounts = append(accounts, acc.acc) + acc.mu.Unlock() + } + return accounts +} + +// ResetDrift resets the drift on an account. +func (a *AccountMgr) ResetDrift(id rhpv3.Account) error { + a.mu.Lock() + account, exists := a.byID[id] + if !exists { + a.mu.Unlock() + return ErrAccountNotFound + } + a.mu.Unlock() + + a.mu.Lock() + account.acc.Drift.SetInt64(0) + a.mu.Unlock() + return nil +} + +func (a *AccountMgr) Shutdown(ctx context.Context) error { + accounts := a.Accounts() + err := a.s.SaveAccounts(ctx, a.owner, accounts, false) + if err != nil { + a.logger.Errorf("failed to save %v accounts: %v", len(accounts), err) + return err + } + a.logger.Infof("successfully saved %v accounts", len(accounts)) + + a.shutdownCancel() + a.wg.Wait() + return nil +} + +func (a *AccountMgr) account(hk types.PublicKey) *Account { + a.mu.Lock() + defer a.mu.Unlock() + + // Derive account key. + accKey := deriveAccountKey(a.key, hk) + accID := rhpv3.Account(accKey.PublicKey()) + + // Create account if it doesn't exist. + acc, exists := a.byID[accID] + if !exists { + acc = &Account{ + key: accKey, + logger: a.logger.Named(accID.String()), + acc: api.Account{ + ID: accID, + CleanShutdown: false, + HostKey: hk, + Balance: big.NewInt(0), + Drift: big.NewInt(0), + RequiresSync: false, + }, + } + a.byID[accID] = acc + } + return acc +} + +// ForHost returns an account to use for a given host. If the account +// doesn't exist, a new one is created. +func (a *AccountMgr) ForHost(hk types.PublicKey) *Account { + return a.account(hk) +} + +func (a *AccountMgr) run() { + // wait for store to become available + var saved []api.Account + var err error + ticker := time.NewTicker(5 * time.Second) + for { + aCtx, cancel := context.WithTimeout(a.shutdownCtx, 30*time.Second) + saved, err = a.s.Accounts(aCtx, a.owner) + cancel() + if err == nil { + break + } + + a.logger.Warn("failed to fetch accounts from bus - retrying in a few seconds", zap.Error(err)) + select { + case <-a.shutdownCtx.Done(): + return + case <-ticker.C: + } + } + + // stop ticker + ticker.Stop() + select { + case <-ticker.C: + default: + } + + // add accounts + a.mu.Lock() + accounts := make(map[rhpv3.Account]*Account, len(saved)) + for _, acc := range saved { + accKey := deriveAccountKey(a.key, acc.HostKey) + if rhpv3.Account(accKey.PublicKey()) != acc.ID { + a.logger.Errorf("account key derivation mismatch %v != %v", accKey.PublicKey(), acc.ID) + continue + } + account := &Account{ + acc: acc, + key: accKey, + logger: a.logger.Named(acc.ID.String()), + } + accounts[account.acc.ID] = account + } + a.mu.Unlock() + + // mark the shutdown as unclean, this will be overwritten on shutdown + err = a.s.SaveAccounts(a.shutdownCtx, a.owner, nil, true) + if err != nil { + a.logger.Error("failed to mark account shutdown as unclean", zap.Error(err)) + } + + ticker = time.NewTicker(a.refillInterval) + for { + select { + case <-a.shutdownCtx.Done(): + return // shutdown + case <-ticker.C: + } + a.refillAccounts() + } +} + +func (a *AccountMgr) markRefillInProgress(hk types.PublicKey) bool { + a.mu.Lock() + defer a.mu.Unlock() + _, inProgress := a.inProgressRefills[hk] + if inProgress { + return false + } + a.inProgressRefills[hk] = struct{}{} + return true +} + +func (a *AccountMgr) markRefillDone(hk types.PublicKey) { + a.mu.Lock() + defer a.mu.Unlock() + _, inProgress := a.inProgressRefills[hk] + if !inProgress { + panic("releasing a refill that hasn't been in progress") + } + delete(a.inProgressRefills, hk) +} + +// refillWorkerAccounts refills all accounts on a worker that require a refill. +// To avoid slow hosts preventing refills for fast hosts, a separate goroutine +// is used for every host. If a slow host's account is still being refilled by a +// goroutine from a previous call, refillWorkerAccounts will skip that account +// until the previously launched goroutine returns. +func (a *AccountMgr) refillAccounts() { + // fetch config + cs, err := a.cs.ConsensusState(a.shutdownCtx) + if err != nil { + a.logger.Errorw(fmt.Sprintf("failed to fetch consensus state for refill: %v", err)) + return + } + + // fetch all contracts + contracts, err := a.dc.DownloadContracts(a.shutdownCtx) + if err != nil { + a.logger.Errorw(fmt.Sprintf("failed to fetch contracts for refill: %v", err)) + return + } else if len(contracts) == 0 { + return + } + + // refill accounts in separate goroutines + for _, c := range contracts { + // launch refill if not already in progress + if a.markRefillInProgress(c.HostKey) { + go func(contract api.ContractMetadata) { + defer a.markRefillDone(contract.HostKey) + + rCtx, cancel := context.WithTimeout(a.shutdownCtx, 5*time.Minute) + defer cancel() + + // refill + err := a.refillAccount(rCtx, c, cs.BlockHeight, a.revisionSubmissionBuffer) + + // determine whether to log something + shouldLog := true + a.mu.Lock() + if t, exists := a.lastLoggedRefillErr[contract.HostKey]; !exists || err == nil { + a.lastLoggedRefillErr[contract.HostKey] = time.Now() + } else if time.Since(t) < time.Hour { + // only log error once per hour per account + shouldLog = false + } + a.mu.Unlock() + + if err != nil && shouldLog { + a.logger.Error("failed to refill account for host", zap.Stringer("hostKey", contract.HostKey), zap.Error(err)) + } else { + a.logger.Infow("successfully refilled account for host", zap.Stringer("hostKey", contract.HostKey), zap.Error(err)) + } + }(c) + } + } +} + +func (a *AccountMgr) refillAccount(ctx context.Context, contract api.ContractMetadata, bh, revisionSubmissionBuffer uint64) error { + // fetch the account + account := a.Account(contract.HostKey) + + // check if the contract is too close to the proof window to be revised, + // trying to refill the account would result in the host not returning the + // revision and returning an obfuscated error + if (bh + revisionSubmissionBuffer) > contract.WindowStart { + return fmt.Errorf("contract %v is too close to the proof window to be revised", contract.ID) + } + + // check if a host is potentially cheating before refilling. + // We only check against the max drift if the account's drift is + // negative because we don't care if we have more money than + // expected. + if account.Drift.Cmp(maxNegDrift) < 0 { + // TODO: register alert + _ = newAccountRefillAlert(account.ID, contract, errMaxDriftExceeded, + "accountID", account.ID.String(), + "hostKey", contract.HostKey.String(), + "balance", account.Balance.String(), + "drift", account.Drift.String(), + ) + return fmt.Errorf("not refilling account since host is potentially cheating: %w", errMaxDriftExceeded) + } else { + // TODO: dismiss alert on success + } + + // check if a resync is needed + if account.RequiresSync { + // sync the account + err := a.w.SyncAccount(ctx, contract.ID, contract.HostKey, contract.SiamuxAddr) + if err != nil { + return fmt.Errorf("failed to sync account's balance: %w", err) + } + + // refetch the account after syncing + account = a.Account(contract.HostKey) + } + + // check if refill is needed + if account.Balance.Cmp(minBalance) >= 0 { + return nil + } + + // fund the account + err := a.w.FundAccount(ctx, contract.ID, contract.HostKey, contract.SiamuxAddr, maxBalance) + if err != nil { + return fmt.Errorf("failed to fund account: %w", err) + } + return nil +} + +// WithSync syncs an accounts balance with the bus. To do so, the account is +// locked while the balance is fetched through balanceFn. +func (a *Account) WithSync(balanceFn func() (types.Currency, error)) error { + a.rwmu.Lock() + defer a.rwmu.Unlock() + + a.mu.Lock() + defer a.mu.Unlock() + + balance, err := balanceFn() + if err != nil { + return err + } + a.setBalance(balance.Big()) + return nil +} + +func (a *Account) ID() rhpv3.Account { + return a.acc.ID +} + +func (a *Account) Key() types.PrivateKey { + return a.key +} + +// WithDeposit increases the balance of an account by the amount returned by +// amtFn if amtFn doesn't return an error. +func (a *Account) WithDeposit(amtFn func(types.Currency) (types.Currency, error)) error { + a.rwmu.RLock() + defer a.rwmu.RUnlock() + + a.mu.Lock() + defer a.mu.Unlock() + + balance := types.NewCurrency(a.acc.Balance.Uint64(), new(big.Int).Rsh(a.acc.Balance, 64).Uint64()) + amt, err := amtFn(balance) + if err != nil { + return err + } + a.addAmount(amt.Big()) + return nil +} + +// WithWithdrawal decreases the balance of an account by the amount returned by +// amtFn. The amount is still withdrawn if amtFn returns an error since some +// costs are non-refundable. +func (a *Account) WithWithdrawal(amtFn func() (types.Currency, error)) error { + a.rwmu.RLock() + defer a.rwmu.RUnlock() + + a.mu.Lock() + defer a.mu.Unlock() + + // return early if the account needs to sync + if a.acc.RequiresSync { + return fmt.Errorf("%w; account requires resync", rhp3.ErrBalanceInsufficient) + } + + // return early if our account is not funded + if a.acc.Balance.Cmp(big.NewInt(0)) <= 0 { + return rhp3.ErrBalanceInsufficient + } + + // execute amtFn + amt, err := amtFn() + + // in case of an insufficient balance, we schedule a sync + if rhp3.IsBalanceInsufficient(err) { + a.scheduleSync() + } + + // if an amount was returned, we withdraw it + if !amt.IsZero() { + a.addAmount(new(big.Int).Neg(amt.Big())) + } + return err +} + +// AddAmount applies the provided amount to an account through addition. So the +// input can be both a positive or negative number depending on whether a +// withdrawal or deposit is recorded. If the account doesn't exist, it is +// created. +func (a *Account) addAmount(amt *big.Int) { + // Update balance. + balanceBefore := a.acc.Balance + a.acc.Balance.Add(a.acc.Balance, amt) + + // Log deposits. + if amt.Cmp(big.NewInt(0)) > 0 { + a.logger.Infow("account balance was increased", + "account", a.acc.ID, + "host", a.acc.HostKey.String(), + "amt", amt.String(), + "balanceBefore", balanceBefore, + "balanceAfter", a.acc.Balance.String()) + } +} + +// scheduleSync sets the requiresSync flag of an account. +func (a *Account) scheduleSync() { + a.mu.Lock() + defer a.mu.Unlock() + + // Only update the sync flag to 'true' if some time has passed since the + // last time it was set. That way we avoid multiple workers setting it after + // failing at the same time, causing multiple syncs in the process. + if time.Since(a.requiresSyncTime) < 30*time.Second { + a.mu.Unlock() + a.logger.Warn("not scheduling account sync since it was scheduled too recently", zap.Stringer("account", a.acc.ID)) + return + } + a.acc.RequiresSync = true + a.requiresSyncTime = time.Now() + + // Log scheduling a sync. + a.logger.Infow("account sync was scheduled", + "account", a.acc.ID, + "host", a.acc.HostKey.String(), + "balance", a.acc.Balance.String(), + "drift", a.acc.Drift.String()) +} + +// setBalance sets the balance of a given account to the provided amount. If the +// account doesn't exist, it is created. +// If an account hasn't been saved successfully upon the last shutdown, no drift +// will be added upon the first call to SetBalance. +func (a *Account) setBalance(balance *big.Int) { + // Update balance and drift. + a.mu.Lock() + delta := new(big.Int).Sub(balance, a.acc.Balance) + balanceBefore := a.acc.Balance.String() + driftBefore := a.acc.Drift.String() + if a.acc.CleanShutdown { + a.acc.Drift = a.acc.Drift.Add(a.acc.Drift, delta) + } + a.acc.Balance.Set(balance) + a.acc.CleanShutdown = true + a.acc.RequiresSync = false // resetting the balance resets the sync field + balanceAfter := a.acc.Balance.String() + a.mu.Unlock() + + // Log resets. + a.logger.Infow("account balance was reset", + "account", a.acc.ID, + "host", a.acc.HostKey.String(), + "balanceBefore", balanceBefore, + "balanceAfter", balanceAfter, + "driftBefore", driftBefore, + "driftAfter", a.acc.Drift.String(), + "delta", delta.String()) +} + +// deriveAccountKey derives an account plus key for a given host and worker. +// Each worker has its own account for a given host. That makes concurrency +// around keeping track of an accounts balance and refilling it a lot easier in +// a multi-worker setup. +func deriveAccountKey(mgrKey types.PrivateKey, hostKey types.PublicKey) types.PrivateKey { + index := byte(0) // not used yet but can be used to derive more than 1 account per host + + // Append the host for which to create it and the index to the + // corresponding sub-key. + subKey := mgrKey + data := make([]byte, 0, len(subKey)+len(hostKey)+1) + data = append(data, subKey[:]...) + data = append(data, hostKey[:]...) + data = append(data, index) + + seed := types.HashBytes(data) + pk := types.NewPrivateKeyFromSeed(seed[:]) + for i := range seed { + seed[i] = 0 + } + return pk +} + +func newAccountRefillAlert(id rhpv3.Account, contract api.ContractMetadata, err error, keysAndValues ...string) alerts.Alert { + data := map[string]interface{}{ + "error": err.Error(), + "accountID": id.String(), + "contractID": contract.ID.String(), + "hostKey": contract.HostKey.String(), + } + for i := 0; i < len(keysAndValues); i += 2 { + data[keysAndValues[i]] = keysAndValues[i+1] + } + + return alerts.Alert{ + ID: alerts.IDForAccount(alertAccountRefillID, id), + Severity: alerts.SeverityError, + Message: "Ephemeral account refill failed", + Data: data, + Timestamp: time.Now(), + } +} diff --git a/stores/accounts.go b/stores/accounts.go index 183582b8b..ce9c64b71 100644 --- a/stores/accounts.go +++ b/stores/accounts.go @@ -8,7 +8,7 @@ import ( ) // Accounts returns all accounts from the db. -func (s *SQLStore) Accounts(ctx context.Context) (accounts []api.Account, err error) { +func (s *SQLStore) Accounts(ctx context.Context, owner string) (accounts []api.Account, err error) { err = s.db.Transaction(ctx, func(tx sql.DatabaseTx) error { accounts, err = tx.Accounts(ctx) return err @@ -20,7 +20,7 @@ func (s *SQLStore) Accounts(ctx context.Context) (accounts []api.Account, err er // and also sets the 'requires_sync' flag. That way, the autopilot will know to // sync all accounts after an unclean shutdown and the bus will know not to // apply drift. -func (s *SQLStore) SetUncleanShutdown(ctx context.Context) error { +func (s *SQLStore) SetUncleanShutdown(ctx context.Context, owner string) error { return s.db.Transaction(ctx, func(tx sql.DatabaseTx) error { return tx.SetUncleanShutdown(ctx) }) @@ -28,7 +28,7 @@ func (s *SQLStore) SetUncleanShutdown(ctx context.Context) error { // SaveAccounts saves the given accounts in the db, overwriting any existing // ones. -func (s *SQLStore) SaveAccounts(ctx context.Context, accounts []api.Account) error { +func (s *SQLStore) SaveAccounts(ctx context.Context, owner string, accounts []api.Account) error { return s.db.Transaction(ctx, func(tx sql.DatabaseTx) error { return tx.SaveAccounts(ctx, accounts) }) diff --git a/worker/accounts.go b/worker/accounts.go deleted file mode 100644 index 76a18d37e..000000000 --- a/worker/accounts.go +++ /dev/null @@ -1,167 +0,0 @@ -package worker - -import ( - "context" - "errors" - "fmt" - "math/big" - "time" - - rhpv3 "go.sia.tech/core/rhp/v3" - "go.sia.tech/core/types" - "go.sia.tech/renterd/api" - rhp3 "go.sia.tech/renterd/internal/rhp/v3" -) - -const ( - // accountLockingDuration is the time for which an account lock remains - // reserved on the bus after locking it. - accountLockingDuration = 30 * time.Second -) - -type ( - // accounts stores the balance and other metrics of accounts that the - // worker maintains with a host. - accounts struct { - as AccountStore - key types.PrivateKey - } - - // account contains information regarding a specific account of the - // worker. - account struct { - as AccountStore - id rhpv3.Account - key types.PrivateKey - host types.PublicKey - } -) - -// ForHost returns an account to use for a given host. If the account -// doesn't exist, a new one is created. -func (a *accounts) ForHost(hk types.PublicKey) *account { - accountID := rhpv3.Account(a.deriveAccountKey(hk).PublicKey()) - return &account{ - as: a.as, - id: accountID, - key: a.key, - host: hk, - } -} - -// deriveAccountKey derives an account plus key for a given host and worker. -// Each worker has its own account for a given host. That makes concurrency -// around keeping track of an accounts balance and refilling it a lot easier in -// a multi-worker setup. -func (a *accounts) deriveAccountKey(hostKey types.PublicKey) types.PrivateKey { - index := byte(0) // not used yet but can be used to derive more than 1 account per host - - // Append the host for which to create it and the index to the - // corresponding sub-key. - subKey := a.key - data := make([]byte, 0, len(subKey)+len(hostKey)+1) - data = append(data, subKey[:]...) - data = append(data, hostKey[:]...) - data = append(data, index) - - seed := types.HashBytes(data) - pk := types.NewPrivateKeyFromSeed(seed[:]) - for i := range seed { - seed[i] = 0 - } - return pk -} - -// Balance returns the account balance. -func (a *account) Balance(ctx context.Context) (balance types.Currency, err error) { - err = withAccountLock(ctx, a.as, a.id, a.host, false, func(account api.Account) error { - balance = types.NewCurrency(account.Balance.Uint64(), new(big.Int).Rsh(account.Balance, 64).Uint64()) - return nil - }) - return -} - -// WithDeposit increases the balance of an account by the amount returned by -// amtFn if amtFn doesn't return an error. -func (a *account) WithDeposit(ctx context.Context, amtFn func() (types.Currency, error)) error { - return withAccountLock(ctx, a.as, a.id, a.host, false, func(_ api.Account) error { - amt, err := amtFn() - if err != nil { - return err - } - return a.as.AddBalance(ctx, a.id, a.host, amt.Big()) - }) -} - -// WithSync syncs an accounts balance with the bus. To do so, the account is -// locked while the balance is fetched through balanceFn. -func (a *account) WithSync(ctx context.Context, balanceFn func() (types.Currency, error)) error { - return withAccountLock(ctx, a.as, a.id, a.host, true, func(_ api.Account) error { - balance, err := balanceFn() - if err != nil { - return err - } - return a.as.SetBalance(ctx, a.id, a.host, balance.Big()) - }) -} - -// WithWithdrawal decreases the balance of an account by the amount returned by -// amtFn. The amount is still withdrawn if amtFn returns an error since some -// costs are non-refundable. -func (a *account) WithWithdrawal(ctx context.Context, amtFn func() (types.Currency, error)) error { - return withAccountLock(ctx, a.as, a.id, a.host, false, func(account api.Account) error { - // return early if the account needs to sync - if account.RequiresSync { - return fmt.Errorf("%w; account requires resync", rhp3.ErrBalanceInsufficient) - } - - // return early if our account is not funded - if account.Balance.Cmp(big.NewInt(0)) <= 0 { - return rhp3.ErrBalanceInsufficient - } - - // execute amtFn - amt, err := amtFn() - - // in case of an insufficient balance, we schedule a sync - if rhp3.IsBalanceInsufficient(err) { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - err = errors.Join(err, a.as.ScheduleSync(ctx, a.id, a.host)) - cancel() - } - - // if an amount was returned, we withdraw it - if !amt.IsZero() { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - err = errors.Join(err, a.as.AddBalance(ctx, a.id, a.host, new(big.Int).Neg(amt.Big()))) - cancel() - } - - return err - }) -} - -func (w *Worker) initAccounts(as AccountStore) { - if w.accounts != nil { - panic("accounts already initialized") // developer error - } - w.accounts = &accounts{ - as: as, - key: w.deriveSubKey("accountkey"), - } -} - -func withAccountLock(ctx context.Context, as AccountStore, id rhpv3.Account, hk types.PublicKey, exclusive bool, fn func(a api.Account) error) error { - acc, lockID, err := as.LockAccount(ctx, id, hk, exclusive, accountLockingDuration) - if err != nil { - return err - } - err = fn(acc) - - // unlock account - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - _ = as.UnlockAccount(ctx, acc.ID, lockID) // ignore error - cancel() - - return err -} diff --git a/worker/client/client.go b/worker/client/client.go index 9abac4d0e..7edfc153a 100644 --- a/worker/client/client.go +++ b/worker/client/client.go @@ -38,6 +38,12 @@ func (c *Client) Account(ctx context.Context, hostKey types.PublicKey) (account return } +// Accounts returns all accounts. +func (c *Client) Accounts(ctx context.Context) (accounts []api.Account, err error) { + err = c.c.WithContext(ctx).GET(fmt.Sprintf("/accounts"), &accounts) + return +} + // Contracts returns all contracts from the worker. These contracts decorate a // bus contract with the contract's latest revision. func (c *Client) Contracts(ctx context.Context, hostTimeout time.Duration) (resp api.ContractsResponse, err error) { diff --git a/worker/host.go b/worker/host.go index 43cbefbd2..ae277fb57 100644 --- a/worker/host.go +++ b/worker/host.go @@ -12,6 +12,7 @@ import ( "go.sia.tech/renterd/api" "go.sia.tech/renterd/internal/gouging" rhp3 "go.sia.tech/renterd/internal/rhp/v3" + "go.sia.tech/renterd/internal/worker" "go.uber.org/zap" ) @@ -41,11 +42,10 @@ type ( host struct { hk types.PublicKey renterKey types.PrivateKey - accountKey types.PrivateKey fcid types.FileContractID siamuxAddr string - acc *account + acc *worker.Account client *rhp3.Client bus Bus contractSpendingRecorder ContractSpendingRecorder @@ -70,7 +70,6 @@ func (w *Worker) Host(hk types.PublicKey, fcid types.FileContractID, siamuxAddr fcid: fcid, siamuxAddr: siamuxAddr, renterKey: w.deriveRenterKey(hk), - accountKey: w.accounts.deriveAccountKey(hk), priceTables: w.priceTables, } } @@ -93,8 +92,8 @@ func (h *host) DownloadSector(ctx context.Context, w io.Writer, root types.Hash2 return fmt.Errorf("%w: %v", gouging.ErrPriceTableGouging, breakdown.DownloadErr) } - return h.acc.WithWithdrawal(ctx, func() (amount types.Currency, err error) { - return h.client.ReadSector(ctx, offset, length, root, w, h.hk, h.siamuxAddr, h.acc.id, h.accountKey, hpt) + return h.acc.WithWithdrawal(func() (amount types.Currency, err error) { + return h.client.ReadSector(ctx, offset, length, root, w, h.hk, h.siamuxAddr, h.acc.ID(), h.acc.Key(), hpt) }) } @@ -105,7 +104,7 @@ func (h *host) UploadSector(ctx context.Context, sectorRoot types.Hash256, secto return err } // upload - cost, err := h.client.AppendSector(ctx, sectorRoot, sector, &rev, h.hk, h.siamuxAddr, h.acc.id, pt, h.renterKey) + cost, err := h.client.AppendSector(ctx, sectorRoot, sector, &rev, h.hk, h.siamuxAddr, h.acc.ID(), pt, h.renterKey) if err != nil { return fmt.Errorf("failed to upload sector: %w", err) } @@ -172,11 +171,11 @@ func (h *host) PriceTable(ctx context.Context, rev *types.FileContractRevision) // pay by contract if a revision is given if rev != nil { - return fetchPT(rhp3.PreparePriceTableContractPayment(rev, h.acc.id, h.renterKey)) + return fetchPT(rhp3.PreparePriceTableContractPayment(rev, h.acc.ID(), h.renterKey)) } // pay by account - return fetchPT(rhp3.PreparePriceTableAccountPayment(h.accountKey)) + return fetchPT(rhp3.PreparePriceTableAccountPayment(h.acc.Key())) } // FetchRevision tries to fetch a contract revision from the host. @@ -191,19 +190,13 @@ func (h *host) FetchRevision(ctx context.Context, fetchTimeout time.Duration) (t } func (h *host) FundAccount(ctx context.Context, balance types.Currency, rev *types.FileContractRevision) error { - // fetch current balance - curr, err := h.acc.Balance(ctx) - if err != nil { - return err - } - - // return early if we have the desired balance - if curr.Cmp(balance) >= 0 { - return nil - } - deposit := balance.Sub(curr) + return h.acc.WithDeposit(func(curr types.Currency) (types.Currency, error) { + // return early if we have the desired balance + if curr.Cmp(balance) >= 0 { + return types.ZeroCurrency, nil + } + deposit := balance.Sub(curr) - return h.acc.WithDeposit(ctx, func() (types.Currency, error) { // fetch pricetable directly to bypass the gouging check pt, err := h.priceTables.fetch(ctx, h.hk, rev) if err != nil { @@ -221,7 +214,7 @@ func (h *host) FundAccount(ctx context.Context, balance types.Currency, rev *typ if deposit.Cmp(availableFunds) > 0 { deposit = availableFunds } - if err := h.client.FundAccount(ctx, rev, h.hk, h.siamuxAddr, deposit, h.acc.id, pt.HostPriceTable, h.renterKey); err != nil { + if err := h.client.FundAccount(ctx, rev, h.hk, h.siamuxAddr, deposit, h.acc.ID(), pt.HostPriceTable, h.renterKey); err != nil { return types.ZeroCurrency, fmt.Errorf("failed to fund account with %v; %w", deposit, err) } // record the spend @@ -245,8 +238,8 @@ func (h *host) SyncAccount(ctx context.Context, rev *types.FileContractRevision) return fmt.Errorf("%w: %v", gouging.ErrPriceTableGouging, err) } - return h.acc.WithSync(ctx, func() (types.Currency, error) { - return h.client.SyncAccount(ctx, rev, h.hk, h.siamuxAddr, h.acc.id, pt.UID, h.renterKey) + return h.acc.WithSync(func() (types.Currency, error) { + return h.client.SyncAccount(ctx, rev, h.hk, h.siamuxAddr, h.acc.ID(), pt.UID, h.renterKey) }) } diff --git a/worker/mocks_test.go b/worker/mocks_test.go index 13e5fd733..ea102710d 100644 --- a/worker/mocks_test.go +++ b/worker/mocks_test.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "math" - "math/big" "sync" "time" @@ -20,35 +19,17 @@ import ( "go.sia.tech/renterd/webhooks" ) -var _ AccountStore = (*accountsMock)(nil) - type accountsMock struct{} -func (*accountsMock) Accounts(context.Context) ([]api.Account, error) { +func (*accountsMock) Accounts(context.Context, string) ([]api.Account, error) { return nil, nil } -func (*accountsMock) AddBalance(context.Context, rhpv3.Account, types.PublicKey, *big.Int) error { +func (*accountsMock) SaveAccounts(context.Context, string, []api.Account, bool) error { return nil } -func (*accountsMock) LockAccount(context.Context, rhpv3.Account, types.PublicKey, bool, time.Duration) (api.Account, uint64, error) { - return api.Account{}, 0, nil -} - -func (*accountsMock) UnlockAccount(context.Context, rhpv3.Account, uint64) error { - return nil -} - -func (*accountsMock) ResetDrift(context.Context, rhpv3.Account) error { - return nil -} - -func (*accountsMock) SetBalance(context.Context, rhpv3.Account, types.PublicKey, *big.Int) error { - return nil -} - -func (*accountsMock) ScheduleSync(context.Context, rhpv3.Account, types.PublicKey) error { +func (*accountsMock) SetUncleanShutdown(context.Context, string) error { return nil } @@ -131,7 +112,6 @@ func (c *contractMock) AddSector(root types.Hash256, sector *[rhpv2.SectorSize]b c.mu.Lock() c.sectors[root] = sector c.mu.Unlock() - return } func (c *contractMock) Sector(root types.Hash256) (sector *[rhpv2.SectorSize]byte, found bool) { diff --git a/worker/worker.go b/worker/worker.go index 9db14e0e9..c34f97799 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "math" - "math/big" "net" "net/http" "os" @@ -77,7 +76,7 @@ type ( gouging.ConsensusState webhooks.Broadcaster - AccountStore + iworker.AccountStore ContractLocker ContractStore HostStore @@ -89,19 +88,6 @@ type ( Wallet } - // An AccountStore manages ephemaral accounts state. - AccountStore interface { - Accounts(ctx context.Context) ([]api.Account, error) - AddBalance(ctx context.Context, id rhpv3.Account, hk types.PublicKey, amt *big.Int) error - - LockAccount(ctx context.Context, id rhpv3.Account, hostKey types.PublicKey, exclusive bool, duration time.Duration) (api.Account, uint64, error) - UnlockAccount(ctx context.Context, id rhpv3.Account, lockID uint64) error - - ResetDrift(ctx context.Context, id rhpv3.Account) error - SetBalance(ctx context.Context, id rhpv3.Account, hk types.PublicKey, amt *big.Int) error - ScheduleSync(ctx context.Context, id rhpv3.Account, hk types.PublicKey) error - } - ContractStore interface { Contract(ctx context.Context, id types.FileContractID) (api.ContractMetadata, error) ContractSize(ctx context.Context, id types.FileContractID) (api.ContractSize, error) @@ -216,7 +202,7 @@ type Worker struct { downloadManager *downloadManager uploadManager *uploadManager - accounts *accounts + accounts *iworker.AccountMgr dialer *rhp.FallbackDialer cache iworker.WorkerCache priceTables *priceTables @@ -613,35 +599,9 @@ func (w *Worker) rhpFundHandler(jc jape.Context) { var rfr api.RHPFundRequest if jc.Decode(&rfr) != nil { return - } - - // attach gouging checker - gp, err := w.bus.GougingParams(ctx) - if jc.Check("could not get gouging parameters", err) != nil { + } else if jc.Check("failed to fund account", w.FundAccount(ctx, rfr.ContractID, rfr.HostKey, rfr.SiamuxAddr, rfr.Balance)) != nil { return } - ctx = WithGougingChecker(ctx, w.bus, gp) - - // fund the account - jc.Check("couldn't fund account", w.withRevision(ctx, defaultRevisionFetchTimeout, rfr.ContractID, rfr.HostKey, rfr.SiamuxAddr, lockingPriorityFunding, func(rev types.FileContractRevision) (err error) { - h := w.Host(rfr.HostKey, rev.ParentID, rfr.SiamuxAddr) - err = h.FundAccount(ctx, rfr.Balance, &rev) - if rhp3.IsBalanceMaxExceeded(err) { - // sync the account - err = h.SyncAccount(ctx, &rev) - if err != nil { - w.logger.Infof(fmt.Sprintf("failed to sync account: %v", err), "host", rfr.HostKey) - return - } - - // try funding the account again - err = h.FundAccount(ctx, rfr.Balance, &rev) - if err != nil { - w.logger.Errorw(fmt.Sprintf("failed to fund account after syncing: %v", err), "host", rfr.HostKey, "balance", rfr.Balance) - } - } - return - })) } func (w *Worker) rhpSyncHandler(jc jape.Context) { @@ -651,20 +611,9 @@ func (w *Worker) rhpSyncHandler(jc jape.Context) { var rsr api.RHPSyncRequest if jc.Decode(&rsr) != nil { return - } - - // attach gouging checker - up, err := w.bus.UploadParams(ctx) - if jc.Check("couldn't fetch upload parameters from bus", err) != nil { + } else if jc.Check("failed to sync account", w.SyncAccount(ctx, rsr.ContractID, rsr.HostKey, rsr.SiamuxAddr)) != nil { return } - ctx = WithGougingChecker(ctx, w.bus, up.GougingParams) - - // sync the account - h := w.Host(rsr.HostKey, rsr.ContractID, rsr.SiamuxAddr) - jc.Check("couldn't sync account", w.withRevision(ctx, defaultRevisionFetchTimeout, rsr.ContractID, rsr.HostKey, rsr.SiamuxAddr, lockingPrioritySyncing, func(rev types.FileContractRevision) error { - return h.SyncAccount(ctx, &rev) - })) } func (w *Worker) slabMigrateHandler(jc jape.Context) { @@ -1178,10 +1127,14 @@ func (w *Worker) accountHandlerGET(jc jape.Context) { if jc.DecodeParam("hostkey", &hostKey) != nil { return } - account := rhpv3.Account(w.accounts.deriveAccountKey(hostKey).PublicKey()) + account := rhpv3.Account(w.accounts.ForHost(hostKey).ID()) jc.Encode(account) } +func (w *Worker) accountsHandlerGET(jc jape.Context) { + jc.Encode(w.accounts.Accounts()) +} + func (w *Worker) eventsHandlerPOST(jc jape.Context) { var event webhooks.Event if jc.Decode(&event) != nil { @@ -1252,7 +1205,9 @@ func New(cfg config.Worker, masterKey [32]byte, b Bus, l *zap.Logger) (*Worker, shutdownCtxCancel: shutdownCancel, } - w.initAccounts(b) + if err := w.initAccounts(); err != nil { + return nil, fmt.Errorf("failed to initialize accounts; %w", err) + } w.initPriceTables() w.initDownloadManager(cfg.DownloadMaxMemory, cfg.DownloadMaxOverdrive, cfg.DownloadOverdriveTimeout, l) @@ -1265,6 +1220,7 @@ func New(cfg config.Worker, masterKey [32]byte, b Bus, l *zap.Logger) (*Worker, // Handler returns an HTTP handler that serves the worker API. func (w *Worker) Handler() http.Handler { return jape.Mux(map[string]jape.Handler{ + "GET /accounts": w.accountsHandlerGET, "GET /account/:hostkey": w.accountHandlerGET, "GET /id": w.idHandlerGET, @@ -1350,7 +1306,7 @@ func (w *Worker) scanHost(ctx context.Context, timeout time.Duration, hostKey ty // fetch the host pricetable scanCtx, cancel = timeoutCtx() - pt, err := w.rhp3Client.PriceTableUnpaid(ctx, hostKey, settings.SiamuxAddr()) + pt, err := w.rhp3Client.PriceTableUnpaid(scanCtx, hostKey, settings.SiamuxAddr()) cancel() if err != nil { return settings, rhpv3.HostPriceTable{}, time.Since(start), err @@ -1469,6 +1425,40 @@ func (w *Worker) headObject(ctx context.Context, bucket, path string, onlyMetada }, res, nil } +func (w *Worker) FundAccount(ctx context.Context, fcid types.FileContractID, hk types.PublicKey, siamuxAddr string, balance types.Currency) error { + // attach gouging checker + gp, err := w.cache.GougingParams(ctx) + if err != nil { + return fmt.Errorf("couldn't get gouging parameters; %w", err) + } + ctx = WithGougingChecker(ctx, w.bus, gp) + + // fund the account + err = w.withRevision(ctx, defaultRevisionFetchTimeout, fcid, hk, siamuxAddr, lockingPriorityFunding, func(rev types.FileContractRevision) (err error) { + h := w.Host(hk, rev.ParentID, siamuxAddr) + err = h.FundAccount(ctx, balance, &rev) + if rhp3.IsBalanceMaxExceeded(err) { + // sync the account + err = h.SyncAccount(ctx, &rev) + if err != nil { + w.logger.Infof(fmt.Sprintf("failed to sync account: %v", err), "host", hk) + return + } + + // try funding the account again + err = h.FundAccount(ctx, balance, &rev) + if err != nil { + w.logger.Errorw(fmt.Sprintf("failed to fund account after syncing: %v", err), "host", hk, "balance", balance) + } + } + return + }) + if err != nil { + return fmt.Errorf("couldn't fund account; %w", err) + } + return nil +} + func (w *Worker) GetObject(ctx context.Context, bucket, path string, opts api.DownloadObjectOptions) (*api.GetObjectResponse, error) { // head object hor, res, err := w.headObject(ctx, bucket, path, false, api.HeadObjectOptions{ @@ -1540,6 +1530,25 @@ func (w *Worker) HeadObject(ctx context.Context, bucket, path string, opts api.H return res, err } +func (w *Worker) SyncAccount(ctx context.Context, fcid types.FileContractID, hk types.PublicKey, siamuxAddr string) error { + // attach gouging checker + gp, err := w.cache.GougingParams(ctx) + if err != nil { + return fmt.Errorf("couldn't get gouging parameters; %w", err) + } + ctx = WithGougingChecker(ctx, w.bus, gp) + + // sync the account + h := w.Host(hk, fcid, siamuxAddr) + err = w.withRevision(ctx, defaultRevisionFetchTimeout, fcid, hk, siamuxAddr, lockingPrioritySyncing, func(rev types.FileContractRevision) error { + return h.SyncAccount(ctx, &rev) + }) + if err != nil { + return fmt.Errorf("failed to sync account; %w", err) + } + return nil +} + func (w *Worker) UploadObject(ctx context.Context, r io.Reader, bucket, path string, opts api.UploadObjectOptions) (*api.UploadObjectResponse, error) { // prepare upload params up, err := w.prepareUploadParams(ctx, bucket, opts.ContractSet, opts.MinShards, opts.TotalShards) @@ -1631,6 +1640,15 @@ func (w *Worker) UploadMultipartUploadPart(ctx context.Context, r io.Reader, buc }, nil } +func (w *Worker) initAccounts() (err error) { + if w.accounts != nil { + panic("priceTables already initialized") // developer error + } + keyPath := fmt.Sprintf("accounts/%s", w.id) + w.accounts, err = iworker.NewAccountManager(w.deriveSubKey(keyPath), w.id, w, w.bus, w.cache, w.bus, 10*time.Second, w.logger.Desugar()) // TODO: refill interval + return err +} + func (w *Worker) prepareUploadParams(ctx context.Context, bucket string, contractSet string, minShards, totalShards int) (api.UploadParams, error) { // return early if the bucket does not exist _, err := w.bus.Bucket(ctx, bucket) From e208dfda5bd071ac3760119ee7ea29e1feff118b Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Wed, 21 Aug 2024 10:54:58 +0200 Subject: [PATCH 09/41] bus: remove accounts --- bus/bus.go | 17 +- bus/client/accounts.go | 71 +----- bus/routes.go | 131 ----------- internal/bus/accounts.go | 337 --------------------------- internal/bus/accounts_test.go | 98 -------- internal/test/e2e/cluster_test.go | 362 +++++++++++++++--------------- internal/worker/accounts.go | 6 +- worker/mocks_test.go | 4 +- 8 files changed, 188 insertions(+), 838 deletions(-) delete mode 100644 internal/bus/accounts.go delete mode 100644 internal/bus/accounts_test.go diff --git a/bus/bus.go b/bus/bus.go index accf9f34c..6dcf8bd3b 100644 --- a/bus/bus.go +++ b/bus/bus.go @@ -366,12 +366,6 @@ func New(ctx context.Context, masterKey [32]byte, am AlertManager, wm WebhooksMa return nil, err } - // create account manager - b.accountsMgr, err = ibus.NewAccountManager(ctx, store, l) - if err != nil { - return nil, err - } - // create contract locker b.contractLocker = ibus.NewContractLocker() @@ -393,15 +387,8 @@ func New(ctx context.Context, masterKey [32]byte, am AlertManager, wm WebhooksMa // Handler returns an HTTP handler that serves the bus API. func (b *Bus) Handler() http.Handler { return jape.Mux(map[string]jape.Handler{ - "GET /accounts": b.accountsHandlerGET, - "POST /accounts": b.accountsHandlerPOST, - "POST /account/:id": b.accountHandlerGET, // deprecated - "POST /account/:id/add": b.accountsAddHandlerPOST, // deprecated - "POST /account/:id/lock": b.accountsLockHandlerPOST, // deprecated - "POST /account/:id/unlock": b.accountsUnlockHandlerPOST, // deprecated - "POST /account/:id/update": b.accountsUpdateHandlerPOST, // deprecated - "POST /account/:id/requiressync": b.accountsRequiresSyncHandlerPOST, // deprecated - "POST /account/:id/resetdrift": b.accountsResetDriftHandlerPOST, // deprecated + "GET /accounts": b.accountsHandlerGET, + "POST /accounts": b.accountsHandlerPOST, "GET /alerts": b.handleGETAlerts, "POST /alerts/dismiss": b.handlePOSTAlertsDismiss, diff --git a/bus/client/accounts.go b/bus/client/accounts.go index 467e8d8d7..834f92de2 100644 --- a/bus/client/accounts.go +++ b/bus/client/accounts.go @@ -3,56 +3,18 @@ package client import ( "context" "fmt" - "math/big" - "time" - rhpv3 "go.sia.tech/core/rhp/v3" - "go.sia.tech/core/types" "go.sia.tech/renterd/api" ) -// Account returns the account for given id. -func (c *Client) Account(ctx context.Context, id rhpv3.Account, hostKey types.PublicKey) (account api.Account, err error) { - err = c.c.WithContext(ctx).POST(fmt.Sprintf("/account/%s", id), api.AccountHandlerPOST{ - HostKey: hostKey, - }, &account) - return -} - // Accounts returns all accounts. func (c *Client) Accounts(ctx context.Context, owner string) (accounts []api.Account, err error) { err = c.c.WithContext(ctx).GET(fmt.Sprintf("/accounts?owner=%s", owner), &accounts) return } -// AddBalance adds the given amount to an account's balance, the amount can be negative. -func (c *Client) AddBalance(ctx context.Context, id rhpv3.Account, hostKey types.PublicKey, amount *big.Int) (err error) { - err = c.c.WithContext(ctx).POST(fmt.Sprintf("/account/%s/add", id), api.AccountsAddBalanceRequest{ - HostKey: hostKey, - Amount: amount, - }, nil) - return -} - -// LockAccount locks an account. -func (c *Client) LockAccount(ctx context.Context, id rhpv3.Account, hostKey types.PublicKey, exclusive bool, duration time.Duration) (account api.Account, lockID uint64, err error) { - var resp api.AccountsLockHandlerResponse - err = c.c.WithContext(ctx).POST(fmt.Sprintf("/account/%s/lock", id), api.AccountsLockHandlerRequest{ - HostKey: hostKey, - Exclusive: exclusive, - Duration: api.DurationMS(duration), - }, &resp) - return resp.Account, resp.LockID, err -} - -// ResetDrift resets the drift of an account to zero. -func (c *Client) ResetDrift(ctx context.Context, id rhpv3.Account) (err error) { - err = c.c.WithContext(ctx).POST(fmt.Sprintf("/account/%s/resetdrift", id), nil, nil) - return -} - -// SaveAccounts saves all accounts. -func (c *Client) SaveAccounts(ctx context.Context, owner string, accounts []api.Account, setUnclean bool) (err error) { +// UpdateAccounts saves all accounts. +func (c *Client) UpdateAccounts(ctx context.Context, owner string, accounts []api.Account, setUnclean bool) (err error) { err = c.c.WithContext(ctx).POST("/accounts", &api.AccountsSaveRequest{ Accounts: accounts, Owner: owner, @@ -60,32 +22,3 @@ func (c *Client) SaveAccounts(ctx context.Context, owner string, accounts []api. }, nil) return } - -// SetBalance sets the given account's balance to a certain amount. -func (c *Client) SetBalance(ctx context.Context, id rhpv3.Account, hostKey types.PublicKey, amount *big.Int) (err error) { - err = c.c.WithContext(ctx).POST(fmt.Sprintf("/account/%s/update", id), api.AccountsUpdateBalanceRequest{ - HostKey: hostKey, - Amount: amount, - }, nil) - return -} - -func (c *Client) SetUncleanShutdown(context.Context, string) error { - panic("not implemented") -} - -// ScheduleSync sets the requiresSync flag of an account. -func (c *Client) ScheduleSync(ctx context.Context, id rhpv3.Account, hostKey types.PublicKey) (err error) { - err = c.c.WithContext(ctx).POST(fmt.Sprintf("/account/%s/requiressync", id), api.AccountsRequiresSyncRequest{ - HostKey: hostKey, - }, nil) - return -} - -// UnlockAccount unlocks an account. -func (c *Client) UnlockAccount(ctx context.Context, id rhpv3.Account, lockID uint64) (err error) { - err = c.c.WithContext(ctx).POST(fmt.Sprintf("/account/%s/unlock", id), api.AccountsUnlockHandlerRequest{ - LockID: lockID, - }, nil) - return -} diff --git a/bus/routes.go b/bus/routes.go index edd293a0d..f6bf888c6 100644 --- a/bus/routes.go +++ b/bus/routes.go @@ -1727,137 +1727,6 @@ func (b *Bus) accountsHandlerPOST(jc jape.Context) { } } -func (b *Bus) accountHandlerGET(jc jape.Context) { - var id rhpv3.Account - if jc.DecodeParam("id", &id) != nil { - return - } - var req api.AccountHandlerPOST - if jc.Decode(&req) != nil { - return - } - acc, err := b.accountsMgr.Account(id, req.HostKey) - if jc.Check("failed to fetch account", err) != nil { - return - } - jc.Encode(acc) -} - -func (b *Bus) accountsAddHandlerPOST(jc jape.Context) { - var id rhpv3.Account - if jc.DecodeParam("id", &id) != nil { - return - } - var req api.AccountsAddBalanceRequest - if jc.Decode(&req) != nil { - return - } - if id == (rhpv3.Account{}) { - jc.Error(errors.New("account id needs to be set"), http.StatusBadRequest) - return - } - if req.HostKey == (types.PublicKey{}) { - jc.Error(errors.New("host needs to be set"), http.StatusBadRequest) - return - } - b.accountsMgr.AddAmount(id, req.HostKey, req.Amount) -} - -func (b *Bus) accountsResetDriftHandlerPOST(jc jape.Context) { - var id rhpv3.Account - if jc.DecodeParam("id", &id) != nil { - return - } - err := b.accountsMgr.ResetDrift(id) - if errors.Is(err, ibus.ErrAccountNotFound) { - jc.Error(err, http.StatusNotFound) - return - } - if jc.Check("failed to reset drift", err) != nil { - return - } -} - -func (b *Bus) accountsUpdateHandlerPOST(jc jape.Context) { - var id rhpv3.Account - if jc.DecodeParam("id", &id) != nil { - return - } - var req api.AccountsUpdateBalanceRequest - if jc.Decode(&req) != nil { - return - } - if id == (rhpv3.Account{}) { - jc.Error(errors.New("account id needs to be set"), http.StatusBadRequest) - return - } - if req.HostKey == (types.PublicKey{}) { - jc.Error(errors.New("host needs to be set"), http.StatusBadRequest) - return - } - b.accountsMgr.SetBalance(id, req.HostKey, req.Amount) -} - -func (b *Bus) accountsRequiresSyncHandlerPOST(jc jape.Context) { - var id rhpv3.Account - if jc.DecodeParam("id", &id) != nil { - return - } - var req api.AccountsRequiresSyncRequest - if jc.Decode(&req) != nil { - return - } - if id == (rhpv3.Account{}) { - jc.Error(errors.New("account id needs to be set"), http.StatusBadRequest) - return - } - if req.HostKey == (types.PublicKey{}) { - jc.Error(errors.New("host needs to be set"), http.StatusBadRequest) - return - } - err := b.accountsMgr.ScheduleSync(id, req.HostKey) - if errors.Is(err, ibus.ErrAccountNotFound) { - jc.Error(err, http.StatusNotFound) - return - } - if jc.Check("failed to set requiresSync flag on account", err) != nil { - return - } -} - -func (b *Bus) accountsLockHandlerPOST(jc jape.Context) { - var id rhpv3.Account - if jc.DecodeParam("id", &id) != nil { - return - } - var req api.AccountsLockHandlerRequest - if jc.Decode(&req) != nil { - return - } - - acc, lockID := b.accountsMgr.LockAccount(jc.Request.Context(), id, req.HostKey, req.Exclusive, time.Duration(req.Duration)) - jc.Encode(api.AccountsLockHandlerResponse{ - Account: acc, - LockID: lockID, - }) -} - -func (b *Bus) accountsUnlockHandlerPOST(jc jape.Context) { - var id rhpv3.Account - if jc.DecodeParam("id", &id) != nil { - return - } - var req api.AccountsUnlockHandlerRequest - if jc.Decode(&req) != nil { - return - } - - err := b.accountsMgr.UnlockAccount(id, req.LockID) - if jc.Check("failed to unlock account", err) != nil { - return - } -} - func (b *Bus) autopilotsListHandlerGET(jc jape.Context) { if autopilots, err := b.as.Autopilots(jc.Request.Context()); jc.Check("failed to fetch autopilots", err) == nil { jc.Encode(autopilots) diff --git a/internal/bus/accounts.go b/internal/bus/accounts.go deleted file mode 100644 index 564bc1d99..000000000 --- a/internal/bus/accounts.go +++ /dev/null @@ -1,337 +0,0 @@ -package bus - -import ( - "context" - "errors" - "fmt" - "math" - "math/big" - "sync" - "time" - - rhpv3 "go.sia.tech/core/rhp/v3" - "go.sia.tech/core/types" - "go.sia.tech/renterd/api" - "go.uber.org/zap" - "lukechampine.com/frand" -) - -const ( - busAccountOwner = "bus" -) - -var ( - ErrAccountNotFound = errors.New("account doesn't exist") -) - -type ( - AccountStore interface { - Accounts(context.Context, string) ([]api.Account, error) - SaveAccounts(context.Context, string, []api.Account) error - SetUncleanShutdown(context.Context, string) error - } -) - -type ( - AccountMgr struct { - s AccountStore - logger *zap.SugaredLogger - - mu sync.Mutex - byID map[rhpv3.Account]*account - } - - account struct { - mu sync.Mutex - locks map[uint64]*accountLock - requiresSyncTime time.Time - api.Account - - rwmu sync.RWMutex - } - - accountLock struct { - heldByID uint64 - unlock func() - timer *time.Timer - } -) - -// NewAccountManager creates a new account manager. It will load all accounts -// from the given store and mark the shutdown as unclean. When Shutdown is -// called it will save all accounts. -func NewAccountManager(ctx context.Context, s AccountStore, logger *zap.Logger) (*AccountMgr, error) { - logger = logger.Named("accounts") - - // load saved accounts - saved, err := s.Accounts(ctx, busAccountOwner) - if err != nil { - return nil, err - } - - // wrap with a lock - accounts := make(map[rhpv3.Account]*account, len(saved)) - for _, acc := range saved { - account := &account{ - Account: acc, - locks: map[uint64]*accountLock{}, - } - accounts[account.ID] = account - } - - // mark the shutdown as unclean, this will be overwritten on shutdown - err = s.SetUncleanShutdown(ctx, busAccountOwner) - if err != nil { - return nil, fmt.Errorf("failed to mark account shutdown as unclean: %w", err) - } - - return &AccountMgr{ - s: s, - logger: logger.Sugar(), - - byID: accounts, - }, nil -} - -// Account returns the account with the given id. -func (a *AccountMgr) Account(id rhpv3.Account, hostKey types.PublicKey) (api.Account, error) { - acc := a.account(id, hostKey) - acc.mu.Lock() - defer acc.mu.Unlock() - return acc.convert(), nil -} - -// Accounts returns all accounts. -func (a *AccountMgr) Accounts() []api.Account { - a.mu.Lock() - defer a.mu.Unlock() - accounts := make([]api.Account, 0, len(a.byID)) - for _, acc := range a.byID { - acc.mu.Lock() - accounts = append(accounts, acc.convert()) - acc.mu.Unlock() - } - return accounts -} - -// AddAmount applies the provided amount to an account through addition. So the -// input can be both a positive or negative number depending on whether a -// withdrawal or deposit is recorded. If the account doesn't exist, it is -// created. -func (a *AccountMgr) AddAmount(id rhpv3.Account, hk types.PublicKey, amt *big.Int) { - acc := a.account(id, hk) - - // Update balance. - acc.mu.Lock() - balanceBefore := acc.Balance.String() - acc.Balance.Add(acc.Balance, amt) - - // Log deposits. - if amt.Cmp(big.NewInt(0)) > 0 { - a.logger.Infow("account balance was increased", - "account", acc.ID, - "host", acc.HostKey.String(), - "amt", amt.String(), - "balanceBefore", balanceBefore, - "balanceAfter", acc.Balance.String()) - } - acc.mu.Unlock() -} - -func (a *AccountMgr) LockAccount(ctx context.Context, id rhpv3.Account, hostKey types.PublicKey, exclusive bool, duration time.Duration) (api.Account, uint64) { - acc := a.account(id, hostKey) - - // Try to lock the account. - if exclusive { - acc.rwmu.Lock() - } else { - acc.rwmu.RLock() - } - - // Create a new lock with an unlock function that can only be called once. - var once sync.Once - heldByID := frand.Uint64n(math.MaxUint64) + 1 - lock := &accountLock{ - heldByID: heldByID, - unlock: func() { - once.Do(func() { - if exclusive { - acc.rwmu.Unlock() - } else { - acc.rwmu.RUnlock() - } - acc.mu.Lock() - delete(acc.locks, heldByID) - acc.mu.Unlock() - }) - }, - } - - // Spawn a timer that will eventually unlock the lock. - lock.timer = time.AfterFunc(duration, lock.unlock) - - acc.mu.Lock() - acc.locks[lock.heldByID] = lock - account := acc.convert() - acc.mu.Unlock() - return account, lock.heldByID -} - -// ResetDrift resets the drift on an account. -func (a *AccountMgr) ResetDrift(id rhpv3.Account) error { - a.mu.Lock() - account, exists := a.byID[id] - if !exists { - a.mu.Unlock() - return ErrAccountNotFound - } - a.mu.Unlock() - account.resetDrift() - return nil -} - -// SetBalance sets the balance of a given account to the provided amount. If the -// account doesn't exist, it is created. -// If an account hasn't been saved successfully upon the last shutdown, no drift -// will be added upon the first call to SetBalance. -func (a *AccountMgr) SetBalance(id rhpv3.Account, hk types.PublicKey, balance *big.Int) { - acc := a.account(id, hk) - - // Update balance and drift. - acc.mu.Lock() - delta := new(big.Int).Sub(balance, acc.Balance) - balanceBefore := acc.Balance.String() - driftBefore := acc.Drift.String() - if acc.CleanShutdown { - acc.Drift = acc.Drift.Add(acc.Drift, delta) - } - acc.Balance.Set(balance) - acc.CleanShutdown = true - acc.RequiresSync = false // resetting the balance resets the sync field - balanceAfter := acc.Balance.String() - acc.mu.Unlock() - - // Log resets. - a.logger.Infow("account balance was reset", - "account", acc.ID, - "host", acc.HostKey.String(), - "balanceBefore", balanceBefore, - "balanceAfter", balanceAfter, - "driftBefore", driftBefore, - "driftAfter", acc.Drift.String(), - "delta", delta.String()) -} - -// ScheduleSync sets the requiresSync flag of an account. -func (a *AccountMgr) ScheduleSync(id rhpv3.Account, hk types.PublicKey) error { - acc := a.account(id, hk) - acc.mu.Lock() - // Only update the sync flag to 'true' if some time has passed since the - // last time it was set. That way we avoid multiple workers setting it after - // failing at the same time, causing multiple syncs in the process. - if time.Since(acc.requiresSyncTime) < 30*time.Second { - acc.mu.Unlock() - return api.ErrRequiresSyncSetRecently - } - acc.RequiresSync = true - acc.requiresSyncTime = time.Now() - - // Log scheduling a sync. - a.logger.Infow("account sync was scheduled", - "account", acc.ID, - "host", acc.HostKey.String(), - "balance", acc.Balance.String(), - "drift", acc.Drift.String()) - acc.mu.Unlock() - - a.mu.Lock() - account, exists := a.byID[id] - defer a.mu.Unlock() - if !exists { - return ErrAccountNotFound - } - account.resetDrift() - return nil -} - -func (a *AccountMgr) Shutdown(ctx context.Context) error { - accounts := a.Accounts() - err := a.s.SaveAccounts(ctx, busAccountOwner, accounts) - if err != nil { - a.logger.Errorf("failed to save %v accounts: %v", len(accounts), err) - return err - } - - a.logger.Infof("successfully saved %v accounts", len(accounts)) - return nil -} - -// UnlockAccount unlocks an account with the given lock id. -func (a *AccountMgr) UnlockAccount(id rhpv3.Account, lockID uint64) error { - a.mu.Lock() - acc, exists := a.byID[id] - if !exists { - a.mu.Unlock() - return ErrAccountNotFound - } - a.mu.Unlock() - - // Get lock. - acc.mu.Lock() - lock, exists := acc.locks[lockID] - acc.mu.Unlock() - if !exists { - return fmt.Errorf("account lock with id %v not found", lockID) - } - - // Stop timer. - lock.timer.Stop() - select { - case <-lock.timer.C: - default: - } - - // Unlock - lock.unlock() - return nil -} - -func (a *AccountMgr) account(id rhpv3.Account, hk types.PublicKey) *account { - a.mu.Lock() - defer a.mu.Unlock() - - // Create account if it doesn't exist. - acc, exists := a.byID[id] - if !exists { - acc = &account{ - Account: api.Account{ - ID: id, - CleanShutdown: false, - HostKey: hk, - Balance: big.NewInt(0), - Drift: big.NewInt(0), - RequiresSync: false, - }, - locks: map[uint64]*accountLock{}, - } - a.byID[id] = acc - } - return acc -} - -func (a *account) convert() api.Account { - return api.Account{ - ID: a.ID, - Balance: new(big.Int).Set(a.Balance), - CleanShutdown: a.CleanShutdown, - Drift: new(big.Int).Set(a.Drift), - HostKey: a.HostKey, - RequiresSync: a.RequiresSync, - } -} - -func (a *account) resetDrift() { - a.mu.Lock() - defer a.mu.Unlock() - a.Drift.SetInt64(0) -} diff --git a/internal/bus/accounts_test.go b/internal/bus/accounts_test.go deleted file mode 100644 index 55e3a3f95..000000000 --- a/internal/bus/accounts_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package bus - -import ( - "context" - "testing" - "time" - - rhpv3 "go.sia.tech/core/rhp/v3" - "go.sia.tech/core/types" - "go.sia.tech/renterd/api" - "go.uber.org/zap" - "lukechampine.com/frand" -) - -type mockAccStore struct{} - -func (m *mockAccStore) Accounts(context.Context, string) ([]api.Account, error) { return nil, nil } -func (m *mockAccStore) SaveAccounts(context.Context, string, []api.Account) error { return nil } -func (m *mockAccStore) SetUncleanShutdown(context.Context, string) error { return nil } - -func TestAccountLocking(t *testing.T) { - eas := &mockAccStore{} - accounts, err := NewAccountManager(context.Background(), eas, zap.NewNop()) - if err != nil { - t.Fatal(err) - } - - var accountID rhpv3.Account - frand.Read(accountID[:]) - var hk types.PublicKey - frand.Read(hk[:]) - - // Lock account non-exclusively a few times. - var lockIDs []uint64 - for i := 0; i < 10; i++ { - acc, lockID := accounts.LockAccount(context.Background(), accountID, hk, false, 30*time.Second) - if lockID == 0 { - t.Fatal("invalid lock id") - } - if acc.ID != accountID { - t.Fatal("wrong id") - } - lockIDs = append(lockIDs, lockID) - } - - // Unlock them again. - for _, lockID := range lockIDs { - err := accounts.UnlockAccount(accountID, lockID) - if err != nil { - t.Fatal("failed to unlock", err) - } - } - - // Acquire exclusive lock. - _, exclusiveLockID := accounts.LockAccount(context.Background(), accountID, hk, true, 30*time.Second) - - // Try acquiring a non-exclusive one. - var sharedLockID uint64 - done := make(chan struct{}) - go func() { - defer close(done) - _, sharedLockID = accounts.LockAccount(context.Background(), accountID, hk, true, 30*time.Second) - }() - - // Wait some time to confirm it's not possible. - select { - case <-done: - t.Fatal("lock was acquired even though exclusive one was held") - case <-time.After(100 * time.Millisecond): - } - - // Unlock exclusive one. - if err := accounts.UnlockAccount(accountID, exclusiveLockID); err != nil { - t.Fatal(err) - } - // Doing so again should fail. - if err := accounts.UnlockAccount(accountID, exclusiveLockID); err == nil { - t.Fatal("should fail") - } - - // Other lock should be acquired now. - select { - case <-time.After(100 * time.Millisecond): - t.Fatal("other lock wasn't acquired") - case <-done: - } - - // Unlock the other lock too. - if err := accounts.UnlockAccount(accountID, sharedLockID); err != nil { - t.Fatal(err) - } - - // Locks should be empty since they clean up after themselves. - acc := accounts.account(accountID, hk) - if len(acc.locks) != 0 { - t.Fatal("should not have any locks", len(acc.locks)) - } -} diff --git a/internal/test/e2e/cluster_test.go b/internal/test/e2e/cluster_test.go index 300cda8ef..fae83430a 100644 --- a/internal/test/e2e/cluster_test.go +++ b/internal/test/e2e/cluster_test.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "math" - "math/big" "reflect" "sort" "strings" @@ -19,7 +18,6 @@ import ( "github.com/google/go-cmp/cmp" rhpv2 "go.sia.tech/core/rhp/v2" - rhpv3 "go.sia.tech/core/rhp/v3" "go.sia.tech/core/types" "go.sia.tech/coreutils/wallet" "go.sia.tech/renterd/alerts" @@ -1124,123 +1122,123 @@ func TestContractApplyChainUpdates(t *testing.T) { } // TestEphemeralAccounts tests the use of ephemeral accounts. -func TestEphemeralAccounts(t *testing.T) { - if testing.Short() { - t.SkipNow() - } - - // Create cluster - cluster := newTestCluster(t, testClusterOptions{hosts: 1}) - defer cluster.Shutdown() - tt := cluster.tt - - // Shut down the autopilot to prevent it from interfering. - cluster.ShutdownAutopilot(context.Background()) - - // Wait for contract and accounts. - contract := cluster.WaitForContracts()[0] - accounts := cluster.WaitForAccounts() - - // Shut down the autopilot to prevent it from interfering with the test. - cluster.ShutdownAutopilot(context.Background()) - - // Newly created accounts are !cleanShutdown. Simulate a sync to change - // that. - for _, acc := range accounts { - if acc.CleanShutdown { - t.Fatal("new account should indicate an unclean shutdown") - } else if acc.RequiresSync { - t.Fatal("new account should not require a sync") - } - if err := cluster.Bus.SetBalance(context.Background(), acc.ID, acc.HostKey, types.Siacoins(1).Big()); err != nil { - t.Fatal(err) - } - } - - // Fetch accounts again. - accounts = cluster.Accounts() - - acc := accounts[0] - if acc.Balance.Cmp(types.Siacoins(1).Big()) < 0 { - t.Fatalf("wrong balance %v", acc.Balance) - } - if acc.ID == (rhpv3.Account{}) { - t.Fatal("account id not set") - } - host := cluster.hosts[0] - if acc.HostKey != types.PublicKey(host.PublicKey()) { - t.Fatal("wrong host") - } - if !acc.CleanShutdown { - t.Fatal("account should indicate a clean shutdown") - } - - // Fetch account from bus directly. - busAccounts := cluster.Accounts() - if len(busAccounts) != 1 { - t.Fatal("expected one account but got", len(busAccounts)) - } - busAcc := busAccounts[0] - if !reflect.DeepEqual(busAcc, acc) { - t.Fatal("bus account doesn't match worker account") - } - - // Check that the spending was recorded for the contract. The recorded - // spending should be > the fundAmt since it consists of the fundAmt plus - // fee. - fundAmt := types.Siacoins(1) - tt.Retry(10, testBusFlushInterval, func() error { - cm, err := cluster.Bus.Contract(context.Background(), contract.ID) - tt.OK(err) - - if cm.Spending.FundAccount.Cmp(fundAmt) <= 0 { - return fmt.Errorf("invalid spending reported: %v > %v", fundAmt.String(), cm.Spending.FundAccount.String()) - } - return nil - }) - - // Update the balance to create some drift. - newBalance := fundAmt.Div64(2) - newDrift := new(big.Int).Sub(newBalance.Big(), fundAmt.Big()) - if err := cluster.Bus.SetBalance(context.Background(), busAcc.ID, acc.HostKey, newBalance.Big()); err != nil { - t.Fatal(err) - } - busAccounts = cluster.Accounts() - busAcc = busAccounts[0] - maxNewDrift := newDrift.Add(newDrift, types.NewCurrency64(2).Big()) // forgive 2H - if busAcc.Drift.Cmp(maxNewDrift) > 0 { - t.Fatalf("drift was %v but should be %v", busAcc.Drift, maxNewDrift) - } - - // Reboot cluster. - cluster2 := cluster.Reboot(t) - defer cluster2.Shutdown() - - // Check that accounts were loaded from the bus. - accounts2 := cluster2.Accounts() - for _, acc := range accounts2 { - if acc.Balance.Cmp(big.NewInt(0)) == 0 { - t.Fatal("account balance wasn't loaded") - } else if acc.Drift.Cmp(big.NewInt(0)) == 0 { - t.Fatal("account drift wasn't loaded") - } else if !acc.CleanShutdown { - t.Fatal("account should indicate a clean shutdown") - } - } - - // Reset drift again. - if err := cluster2.Bus.ResetDrift(context.Background(), acc.ID); err != nil { - t.Fatal(err) - } - accounts2 = cluster2.Accounts() - if accounts2[0].Drift.Cmp(new(big.Int)) != 0 { - t.Fatal("drift wasn't reset", accounts2[0].Drift.String()) - } - accounts2 = cluster2.Accounts() - if accounts2[0].Drift.Cmp(new(big.Int)) != 0 { - t.Fatal("drift wasn't reset", accounts2[0].Drift.String()) - } -} +// func TestEphemeralAccounts(t *testing.T) { +// if testing.Short() { +// t.SkipNow() +// } +// +// // Create cluster +// cluster := newTestCluster(t, testClusterOptions{hosts: 1}) +// defer cluster.Shutdown() +// tt := cluster.tt +// +// // Shut down the autopilot to prevent it from interfering. +// cluster.ShutdownAutopilot(context.Background()) +// +// // Wait for contract and accounts. +// contract := cluster.WaitForContracts()[0] +// accounts := cluster.WaitForAccounts() +// +// // Shut down the autopilot to prevent it from interfering with the test. +// cluster.ShutdownAutopilot(context.Background()) +// +// // Newly created accounts are !cleanShutdown. Simulate a sync to change +// // that. +// for _, acc := range accounts { +// if acc.CleanShutdown { +// t.Fatal("new account should indicate an unclean shutdown") +// } else if acc.RequiresSync { +// t.Fatal("new account should not require a sync") +// } +// if err := cluster.Bus.SetBalance(context.Background(), acc.ID, acc.HostKey, types.Siacoins(1).Big()); err != nil { +// t.Fatal(err) +// } +// } +// +// // Fetch accounts again. +// accounts = cluster.Accounts() +// +// acc := accounts[0] +// if acc.Balance.Cmp(types.Siacoins(1).Big()) < 0 { +// t.Fatalf("wrong balance %v", acc.Balance) +// } +// if acc.ID == (rhpv3.Account{}) { +// t.Fatal("account id not set") +// } +// host := cluster.hosts[0] +// if acc.HostKey != types.PublicKey(host.PublicKey()) { +// t.Fatal("wrong host") +// } +// if !acc.CleanShutdown { +// t.Fatal("account should indicate a clean shutdown") +// } +// +// // Fetch account from bus directly. +// busAccounts := cluster.Accounts() +// if len(busAccounts) != 1 { +// t.Fatal("expected one account but got", len(busAccounts)) +// } +// busAcc := busAccounts[0] +// if !reflect.DeepEqual(busAcc, acc) { +// t.Fatal("bus account doesn't match worker account") +// } +// +// // Check that the spending was recorded for the contract. The recorded +// // spending should be > the fundAmt since it consists of the fundAmt plus +// // fee. +// fundAmt := types.Siacoins(1) +// tt.Retry(10, testBusFlushInterval, func() error { +// cm, err := cluster.Bus.Contract(context.Background(), contract.ID) +// tt.OK(err) +// +// if cm.Spending.FundAccount.Cmp(fundAmt) <= 0 { +// return fmt.Errorf("invalid spending reported: %v > %v", fundAmt.String(), cm.Spending.FundAccount.String()) +// } +// return nil +// }) +// +// // Update the balance to create some drift. +// newBalance := fundAmt.Div64(2) +// newDrift := new(big.Int).Sub(newBalance.Big(), fundAmt.Big()) +// if err := cluster.Bus.SetBalance(context.Background(), busAcc.ID, acc.HostKey, newBalance.Big()); err != nil { +// t.Fatal(err) +// } +// busAccounts = cluster.Accounts() +// busAcc = busAccounts[0] +// maxNewDrift := newDrift.Add(newDrift, types.NewCurrency64(2).Big()) // forgive 2H +// if busAcc.Drift.Cmp(maxNewDrift) > 0 { +// t.Fatalf("drift was %v but should be %v", busAcc.Drift, maxNewDrift) +// } +// +// // Reboot cluster. +// cluster2 := cluster.Reboot(t) +// defer cluster2.Shutdown() +// +// // Check that accounts were loaded from the bus. +// accounts2 := cluster2.Accounts() +// for _, acc := range accounts2 { +// if acc.Balance.Cmp(big.NewInt(0)) == 0 { +// t.Fatal("account balance wasn't loaded") +// } else if acc.Drift.Cmp(big.NewInt(0)) == 0 { +// t.Fatal("account drift wasn't loaded") +// } else if !acc.CleanShutdown { +// t.Fatal("account should indicate a clean shutdown") +// } +// } +// +// // Reset drift again. +// if err := cluster2.Bus.ResetDrift(context.Background(), acc.ID); err != nil { +// t.Fatal(err) +// } +// accounts2 = cluster2.Accounts() +// if accounts2[0].Drift.Cmp(new(big.Int)) != 0 { +// t.Fatal("drift wasn't reset", accounts2[0].Drift.String()) +// } +// accounts2 = cluster2.Accounts() +// if accounts2[0].Drift.Cmp(new(big.Int)) != 0 { +// t.Fatal("drift wasn't reset", accounts2[0].Drift.String()) +// } +//} // TestParallelUpload tests uploading multiple files in parallel. func TestParallelUpload(t *testing.T) { @@ -1365,69 +1363,69 @@ func TestParallelDownload(t *testing.T) { // TestEphemeralAccountSync verifies that setting the requiresSync flag makes // the autopilot resync the balance between renter and host. -func TestEphemeralAccountSync(t *testing.T) { - if testing.Short() { - t.SkipNow() - } - - dir := t.TempDir() - cluster := newTestCluster(t, testClusterOptions{ - dir: dir, - hosts: 1, - }) - tt := cluster.tt - - // Shut down the autopilot to prevent it from manipulating the account. - cluster.ShutdownAutopilot(context.Background()) - - // Fetch the account balance before setting the balance - accounts := cluster.Accounts() - if len(accounts) != 1 || accounts[0].RequiresSync { - t.Fatal("account shouldn't require a sync") - } - acc := accounts[0] - - // Set requiresSync flag on bus and balance to 0. - if err := cluster.Bus.SetBalance(context.Background(), acc.ID, acc.HostKey, new(big.Int)); err != nil { - t.Fatal(err) - } - if err := cluster.Bus.ScheduleSync(context.Background(), acc.ID, acc.HostKey); err != nil { - t.Fatal(err) - } - accounts = cluster.Accounts() - if len(accounts) != 1 || !accounts[0].RequiresSync { - t.Fatal("account wasn't updated") - } - - // Restart cluster to have worker fetch the account from the bus again. - cluster2 := cluster.Reboot(t) - defer cluster2.Shutdown() - - // Account should need a sync. - account, err := cluster2.Bus.Account(context.Background(), acc.ID, acc.HostKey) - tt.OK(err) - if !account.RequiresSync { - t.Fatal("flag wasn't persisted") - } - - // Wait for autopilot to sync and reset flag. - tt.Retry(100, 100*time.Millisecond, func() error { - account, err := cluster2.Bus.Account(context.Background(), acc.ID, acc.HostKey) - if err != nil { - t.Fatal(err) - } - if account.RequiresSync { - return errors.New("account wasn't synced") - } - return nil - }) - - // Flag should also be reset on bus now. - accounts = cluster2.Accounts() - if len(accounts) != 1 || accounts[0].RequiresSync { - t.Fatal("account wasn't updated") - } -} +// func TestEphemeralAccountSync(t *testing.T) { +// if testing.Short() { +// t.SkipNow() +// } +// +// dir := t.TempDir() +// cluster := newTestCluster(t, testClusterOptions{ +// dir: dir, +// hosts: 1, +// }) +// tt := cluster.tt +// +// // Shut down the autopilot to prevent it from manipulating the account. +// cluster.ShutdownAutopilot(context.Background()) +// +// // Fetch the account balance before setting the balance +// accounts := cluster.Accounts() +// if len(accounts) != 1 || accounts[0].RequiresSync { +// t.Fatal("account shouldn't require a sync") +// } +// acc := accounts[0] +// +// // Set requiresSync flag on bus and balance to 0. +// if err := cluster.Bus.SetBalance(context.Background(), acc.ID, acc.HostKey, new(big.Int)); err != nil { +// t.Fatal(err) +// } +// if err := cluster.Bus.ScheduleSync(context.Background(), acc.ID, acc.HostKey); err != nil { +// t.Fatal(err) +// } +// accounts = cluster.Accounts() +// if len(accounts) != 1 || !accounts[0].RequiresSync { +// t.Fatal("account wasn't updated") +// } +// +// // Restart cluster to have worker fetch the account from the bus again. +// cluster2 := cluster.Reboot(t) +// defer cluster2.Shutdown() +// +// // Account should need a sync. +// account, err := cluster2.Bus.Account(context.Background(), acc.ID, acc.HostKey) +// tt.OK(err) +// if !account.RequiresSync { +// t.Fatal("flag wasn't persisted") +// } +// +// // Wait for autopilot to sync and reset flag. +// tt.Retry(100, 100*time.Millisecond, func() error { +// account, err := cluster2.Bus.Account(context.Background(), acc.ID, acc.HostKey) +// if err != nil { +// t.Fatal(err) +// } +// if account.RequiresSync { +// return errors.New("account wasn't synced") +// } +// return nil +// }) +// +// // Flag should also be reset on bus now. +// accounts = cluster2.Accounts() +// if len(accounts) != 1 || accounts[0].RequiresSync { +// t.Fatal("account wasn't updated") +// } +//} // TestUploadDownloadSameHost uploads a file to the same host through different // contracts and tries downloading the file again. diff --git a/internal/worker/accounts.go b/internal/worker/accounts.go index 3946b0d07..2f3685fbd 100644 --- a/internal/worker/accounts.go +++ b/internal/worker/accounts.go @@ -38,7 +38,7 @@ type ( AccountStore interface { Accounts(context.Context, string) ([]api.Account, error) - SaveAccounts(context.Context, string, []api.Account, bool) error + UpdateAccounts(context.Context, string, []api.Account, bool) error } ConsensusState interface { @@ -154,7 +154,7 @@ func (a *AccountMgr) ResetDrift(id rhpv3.Account) error { func (a *AccountMgr) Shutdown(ctx context.Context) error { accounts := a.Accounts() - err := a.s.SaveAccounts(ctx, a.owner, accounts, false) + err := a.s.UpdateAccounts(ctx, a.owner, accounts, false) if err != nil { a.logger.Errorf("failed to save %v accounts: %v", len(accounts), err) return err @@ -247,7 +247,7 @@ func (a *AccountMgr) run() { a.mu.Unlock() // mark the shutdown as unclean, this will be overwritten on shutdown - err = a.s.SaveAccounts(a.shutdownCtx, a.owner, nil, true) + err = a.s.UpdateAccounts(a.shutdownCtx, a.owner, nil, true) if err != nil { a.logger.Error("failed to mark account shutdown as unclean", zap.Error(err)) } diff --git a/worker/mocks_test.go b/worker/mocks_test.go index ea102710d..79824ddce 100644 --- a/worker/mocks_test.go +++ b/worker/mocks_test.go @@ -25,7 +25,7 @@ func (*accountsMock) Accounts(context.Context, string) ([]api.Account, error) { return nil, nil } -func (*accountsMock) SaveAccounts(context.Context, string, []api.Account, bool) error { +func (*accountsMock) UpdateAccounts(context.Context, string, []api.Account, bool) error { return nil } @@ -53,8 +53,6 @@ func (c *chainMock) ConsensusState(ctx context.Context) (api.ConsensusState, err return c.cs, nil } -var _ Bus = (*busMock)(nil) - type busMock struct { *alerterMock *accountsMock From fc177fc5df0ddf44cbd8968b8c824cd7c364e586 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 10:55:07 +0200 Subject: [PATCH 10/41] bus: remove AccountManager --- bus/bus.go | 64 ++++++++++++++---------------------- bus/client/accounts.go | 8 +++-- bus/routes.go | 23 ++++++++++--- cmd/renterd/config.go | 10 +++--- config/config.go | 2 +- internal/test/e2e/cluster.go | 2 +- worker/worker.go | 6 ++-- 7 files changed, 58 insertions(+), 57 deletions(-) diff --git a/bus/bus.go b/bus/bus.go index 6dcf8bd3b..9bc310bfc 100644 --- a/bus/bus.go +++ b/bus/bus.go @@ -8,7 +8,6 @@ import ( "encoding/json" "errors" "fmt" - "math/big" "net" "net/http" "strings" @@ -17,7 +16,6 @@ import ( "go.sia.tech/core/consensus" "go.sia.tech/core/gateway" rhpv2 "go.sia.tech/core/rhp/v2" - rhpv3 "go.sia.tech/core/rhp/v3" "go.sia.tech/core/types" "go.sia.tech/coreutils/chain" "go.sia.tech/coreutils/syncer" @@ -59,18 +57,6 @@ func NewClient(addr, password string) *Client { } type ( - AccountManager interface { - Account(id rhpv3.Account, hostKey types.PublicKey) (api.Account, error) - Accounts() []api.Account - AddAmount(id rhpv3.Account, hk types.PublicKey, amt *big.Int) - LockAccount(ctx context.Context, id rhpv3.Account, hostKey types.PublicKey, exclusive bool, duration time.Duration) (api.Account, uint64) - ResetDrift(id rhpv3.Account) error - SetBalance(id rhpv3.Account, hk types.PublicKey, balance *big.Int) - ScheduleSync(id rhpv3.Account, hk types.PublicKey) error - Shutdown(context.Context) error - UnlockAccount(id rhpv3.Account, lockID uint64) error - } - AlertManager interface { alerts.Alerter RegisterWebhookBroadcaster(b webhooks.Broadcaster) @@ -309,22 +295,21 @@ type Bus struct { startTime time.Time masterKey [32]byte - accountsMgr AccountManager - alerts alerts.Alerter - alertMgr AlertManager - pinMgr PinManager - webhooksMgr WebhooksManager - accountStore AccountStore - cm ChainManager - cs ChainSubscriber - s Syncer - w Wallet - - as AutopilotStore - hs HostStore - ms MetadataStore - mtrcs MetricsStore - ss SettingStore + alerts alerts.Alerter + alertMgr AlertManager + pinMgr PinManager + webhooksMgr WebhooksManager + cm ChainManager + cs ChainSubscriber + s Syncer + w Wallet + + accounts AccountStore + as AutopilotStore + hs HostStore + ms MetadataStore + mtrcs MetricsStore + ss SettingStore rhp2 *rhp2.Client @@ -343,15 +328,15 @@ func New(ctx context.Context, masterKey [32]byte, am AlertManager, wm WebhooksMa startTime: time.Now(), masterKey: masterKey, - accountStore: store, - s: s, - cm: cm, - w: w, - hs: store, - as: store, - ms: store, - mtrcs: store, - ss: store, + accounts: store, + s: s, + cm: cm, + w: w, + hs: store, + as: store, + ms: store, + mtrcs: store, + ss: store, alerts: alerts.WithOrigin(am, "bus"), alertMgr: am, @@ -525,7 +510,6 @@ func (b *Bus) Handler() http.Handler { func (b *Bus) Shutdown(ctx context.Context) error { return errors.Join( b.walletMetricsRecorder.Shutdown(ctx), - b.accountsMgr.Shutdown(ctx), b.webhooksMgr.Shutdown(ctx), b.pinMgr.Shutdown(ctx), b.cs.Shutdown(ctx), diff --git a/bus/client/accounts.go b/bus/client/accounts.go index 834f92de2..f9a79290e 100644 --- a/bus/client/accounts.go +++ b/bus/client/accounts.go @@ -2,20 +2,22 @@ package client import ( "context" - "fmt" + "net/url" "go.sia.tech/renterd/api" ) // Accounts returns all accounts. func (c *Client) Accounts(ctx context.Context, owner string) (accounts []api.Account, err error) { - err = c.c.WithContext(ctx).GET(fmt.Sprintf("/accounts?owner=%s", owner), &accounts) + values := url.Values{} + values.Set("owner", owner) + err = c.c.WithContext(ctx).GET("/accounts?"+values.Encode(), &accounts) return } // UpdateAccounts saves all accounts. func (c *Client) UpdateAccounts(ctx context.Context, owner string, accounts []api.Account, setUnclean bool) (err error) { - err = c.c.WithContext(ctx).POST("/accounts", &api.AccountsSaveRequest{ + err = c.c.WithContext(ctx).POST("/accounts", api.AccountsSaveRequest{ Accounts: accounts, Owner: owner, SetUnclean: setUnclean, diff --git a/bus/routes.go b/bus/routes.go index f6bf888c6..f564b822b 100644 --- a/bus/routes.go +++ b/bus/routes.go @@ -1711,18 +1711,33 @@ func (b *Bus) handlePOSTAlertsRegister(jc jape.Context) { } func (b *Bus) accountsHandlerGET(jc jape.Context) { - jc.Encode(b.accountsMgr.Accounts()) + var owner string + if jc.DecodeForm("owner", &owner) != nil { + return + } else if owner == "" { + jc.Error(errors.New("owner is required"), http.StatusBadRequest) + return + } + accounts, err := b.accounts.Accounts(jc.Request.Context(), owner) + if err != nil { + jc.Error(err, http.StatusInternalServerError) + return + } + jc.Encode(accounts) } func (b *Bus) accountsHandlerPOST(jc jape.Context) { var req api.AccountsSaveRequest - if jc.Decode(&req) != nil { + if req.Owner == "" { + jc.Error(errors.New("owner is required"), http.StatusBadRequest) + return + } else if jc.Decode(&req) != nil { return - } else if b.accountStore.SaveAccounts(jc.Request.Context(), req.Owner, req.Accounts) != nil { + } else if b.accounts.SaveAccounts(jc.Request.Context(), req.Owner, req.Accounts) != nil { return } else if !req.SetUnclean { return - } else if jc.Check("failed to set accounts unclean", b.accountStore.SetUncleanShutdown(jc.Request.Context(), req.Owner)) != nil { + } else if jc.Check("failed to set accounts unclean", b.accounts.SetUncleanShutdown(jc.Request.Context(), req.Owner)) != nil { return } } diff --git a/cmd/renterd/config.go b/cmd/renterd/config.go index 38231458d..da85dabc8 100644 --- a/cmd/renterd/config.go +++ b/cmd/renterd/config.go @@ -97,9 +97,10 @@ func defaultConfig() config.Config { Worker: config.Worker{ Enabled: true, - ID: "worker", - ContractLockTimeout: 30 * time.Second, - BusFlushInterval: 5 * time.Second, + ID: "worker", + AccountsRefillInterval: defaultAccountRefillInterval, + ContractLockTimeout: 30 * time.Second, + BusFlushInterval: 5 * time.Second, DownloadMaxOverdrive: 5, DownloadOverdriveTimeout: 3 * time.Second, @@ -114,7 +115,6 @@ func defaultConfig() config.Config { ID: api.DefaultAutopilotID, RevisionSubmissionBuffer: 150, // 144 + 6 blocks leeway - AccountsRefillInterval: defaultAccountRefillInterval, Heartbeat: 30 * time.Minute, MigrationHealthCutoff: 0.75, RevisionBroadcastInterval: 7 * 24 * time.Hour, @@ -294,6 +294,7 @@ func parseCLIFlags(cfg *config.Config) { flag.Int64Var(&cfg.Bus.SlabBufferCompletionThreshold, "bus.slabBufferCompletionThreshold", cfg.Bus.SlabBufferCompletionThreshold, "Threshold for slab buffer upload (overrides with RENTERD_BUS_SLAB_BUFFER_COMPLETION_THRESHOLD)") // worker + flag.DurationVar(&cfg.Worker.AccountsRefillInterval, "worker.accountRefillInterval", cfg.Worker.AccountsRefillInterval, "Interval for refilling workers' account balances") flag.BoolVar(&cfg.Worker.AllowPrivateIPs, "worker.allowPrivateIPs", cfg.Worker.AllowPrivateIPs, "Allows hosts with private IPs") flag.DurationVar(&cfg.Worker.BusFlushInterval, "worker.busFlushInterval", cfg.Worker.BusFlushInterval, "Interval for flushing data to bus") flag.Uint64Var(&cfg.Worker.DownloadMaxMemory, "worker.downloadMaxMemory", cfg.Worker.DownloadMaxMemory, "Max amount of RAM the worker allocates for slabs when downloading (overrides with RENTERD_WORKER_DOWNLOAD_MAX_MEMORY)") @@ -308,7 +309,6 @@ func parseCLIFlags(cfg *config.Config) { flag.StringVar(&cfg.Worker.ExternalAddress, "worker.externalAddress", cfg.Worker.ExternalAddress, "Address of the worker on the network, only necessary when the bus is remote (overrides with RENTERD_WORKER_EXTERNAL_ADDR)") // autopilot - flag.DurationVar(&cfg.Autopilot.AccountsRefillInterval, "autopilot.accountRefillInterval", cfg.Autopilot.AccountsRefillInterval, "Interval for refilling workers' account balances") flag.DurationVar(&cfg.Autopilot.Heartbeat, "autopilot.heartbeat", cfg.Autopilot.Heartbeat, "Interval for autopilot loop execution") flag.Float64Var(&cfg.Autopilot.MigrationHealthCutoff, "autopilot.migrationHealthCutoff", cfg.Autopilot.MigrationHealthCutoff, "Threshold for migrating slabs based on health") flag.DurationVar(&cfg.Autopilot.RevisionBroadcastInterval, "autopilot.revisionBroadcastInterval", cfg.Autopilot.RevisionBroadcastInterval, "Interval for broadcasting contract revisions (overrides with RENTERD_AUTOPILOT_REVISION_BROADCAST_INTERVAL)") diff --git a/config/config.go b/config/config.go index 99382240b..6755d3869 100644 --- a/config/config.go +++ b/config/config.go @@ -117,6 +117,7 @@ type ( Enabled bool `yaml:"enabled,omitempty"` ID string `yaml:"id,omitempty"` Remotes []RemoteWorker `yaml:"remotes,omitempty"` + AccountsRefillInterval time.Duration `yaml:"accountsRefillInterval,omitempty"` AllowPrivateIPs bool `yaml:"allowPrivateIPs,omitempty"` BusFlushInterval time.Duration `yaml:"busFlushInterval,omitempty"` ContractLockTimeout time.Duration `yaml:"contractLockTimeout,omitempty"` @@ -134,7 +135,6 @@ type ( Autopilot struct { Enabled bool `yaml:"enabled,omitempty"` ID string `yaml:"id,omitempty"` - AccountsRefillInterval time.Duration `yaml:"accountsRefillInterval,omitempty"` Heartbeat time.Duration `yaml:"heartbeat,omitempty"` MigrationHealthCutoff float64 `yaml:"migrationHealthCutoff,omitempty"` RevisionBroadcastInterval time.Duration `yaml:"revisionBroadcastInterval,omitempty"` diff --git a/internal/test/e2e/cluster.go b/internal/test/e2e/cluster.go index 668683a25..99381058f 100644 --- a/internal/test/e2e/cluster.go +++ b/internal/test/e2e/cluster.go @@ -1042,6 +1042,7 @@ func testDBCfg() dbConfig { func testWorkerCfg() config.Worker { return config.Worker{ + AccountsRefillInterval: time.Second, AllowPrivateIPs: true, ContractLockTimeout: 5 * time.Second, ID: "worker", @@ -1056,7 +1057,6 @@ func testWorkerCfg() config.Worker { func testApCfg() config.Autopilot { return config.Autopilot{ - AccountsRefillInterval: time.Second, Heartbeat: time.Second, ID: api.DefaultAutopilotID, MigrationHealthCutoff: 0.99, diff --git a/worker/worker.go b/worker/worker.go index c34f97799..5ee311343 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -1205,7 +1205,7 @@ func New(cfg config.Worker, masterKey [32]byte, b Bus, l *zap.Logger) (*Worker, shutdownCtxCancel: shutdownCancel, } - if err := w.initAccounts(); err != nil { + if err := w.initAccounts(cfg.AccountsRefillInterval); err != nil { return nil, fmt.Errorf("failed to initialize accounts; %w", err) } w.initPriceTables() @@ -1640,12 +1640,12 @@ func (w *Worker) UploadMultipartUploadPart(ctx context.Context, r io.Reader, buc }, nil } -func (w *Worker) initAccounts() (err error) { +func (w *Worker) initAccounts(refillInterval time.Duration) (err error) { if w.accounts != nil { panic("priceTables already initialized") // developer error } keyPath := fmt.Sprintf("accounts/%s", w.id) - w.accounts, err = iworker.NewAccountManager(w.deriveSubKey(keyPath), w.id, w, w.bus, w.cache, w.bus, 10*time.Second, w.logger.Desugar()) // TODO: refill interval + w.accounts, err = iworker.NewAccountManager(w.deriveSubKey(keyPath), w.id, w, w.bus, w.cache, w.bus, refillInterval, w.logger.Desugar()) return err } From 349e1ce719671b4b642bb6b84d3394c42c5f1779 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Wed, 21 Aug 2024 11:13:40 +0200 Subject: [PATCH 11/41] worker: fix TestDownloaderStopped --- worker/worker_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/worker/worker_test.go b/worker/worker_test.go index f0822f03f..4472f64b5 100644 --- a/worker/worker_test.go +++ b/worker/worker_test.go @@ -132,6 +132,7 @@ func (w *testWorker) RenewContract(hk types.PublicKey) *contractMock { func newTestWorkerCfg() config.Worker { return config.Worker{ + AccountsRefillInterval: time.Second, ID: "test", ContractLockTimeout: time.Second, BusFlushInterval: time.Second, From e891bb7625913a675ae4b26d86d9ae0bbdbb3f6c Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Wed, 21 Aug 2024 11:55:54 +0200 Subject: [PATCH 12/41] worker: update locking --- internal/worker/accounts.go | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/internal/worker/accounts.go b/internal/worker/accounts.go index 2f3685fbd..7fccf0bf1 100644 --- a/internal/worker/accounts.go +++ b/internal/worker/accounts.go @@ -146,10 +146,14 @@ func (a *AccountMgr) ResetDrift(id rhpv3.Account) error { } a.mu.Unlock() + account.resetDrift() + return nil +} + +func (a *Account) resetDrift() { a.mu.Lock() - account.acc.Drift.SetInt64(0) + a.acc.Drift.SetInt64(0) a.mu.Unlock() - return nil } func (a *AccountMgr) Shutdown(ctx context.Context) error { @@ -399,13 +403,11 @@ func (a *Account) WithSync(balanceFn func() (types.Currency, error)) error { a.rwmu.Lock() defer a.rwmu.Unlock() - a.mu.Lock() - defer a.mu.Unlock() - balance, err := balanceFn() if err != nil { return err } + a.setBalance(balance.Big()) return nil } @@ -425,9 +427,9 @@ func (a *Account) WithDeposit(amtFn func(types.Currency) (types.Currency, error) defer a.rwmu.RUnlock() a.mu.Lock() - defer a.mu.Unlock() - balance := types.NewCurrency(a.acc.Balance.Uint64(), new(big.Int).Rsh(a.acc.Balance, 64).Uint64()) + a.mu.Unlock() + amt, err := amtFn(balance) if err != nil { return err @@ -443,18 +445,19 @@ func (a *Account) WithWithdrawal(amtFn func() (types.Currency, error)) error { a.rwmu.RLock() defer a.rwmu.RUnlock() - a.mu.Lock() - defer a.mu.Unlock() - // return early if the account needs to sync + a.mu.Lock() if a.acc.RequiresSync { + a.mu.Unlock() return fmt.Errorf("%w; account requires resync", rhp3.ErrBalanceInsufficient) } // return early if our account is not funded if a.acc.Balance.Cmp(big.NewInt(0)) <= 0 { + a.mu.Unlock() return rhp3.ErrBalanceInsufficient } + a.mu.Unlock() // execute amtFn amt, err := amtFn() @@ -476,6 +479,9 @@ func (a *Account) WithWithdrawal(amtFn func() (types.Currency, error)) error { // withdrawal or deposit is recorded. If the account doesn't exist, it is // created. func (a *Account) addAmount(amt *big.Int) { + a.mu.Lock() + defer a.mu.Unlock() + // Update balance. balanceBefore := a.acc.Balance a.acc.Balance.Add(a.acc.Balance, amt) @@ -500,7 +506,6 @@ func (a *Account) scheduleSync() { // last time it was set. That way we avoid multiple workers setting it after // failing at the same time, causing multiple syncs in the process. if time.Since(a.requiresSyncTime) < 30*time.Second { - a.mu.Unlock() a.logger.Warn("not scheduling account sync since it was scheduled too recently", zap.Stringer("account", a.acc.ID)) return } @@ -520,8 +525,10 @@ func (a *Account) scheduleSync() { // If an account hasn't been saved successfully upon the last shutdown, no drift // will be added upon the first call to SetBalance. func (a *Account) setBalance(balance *big.Int) { - // Update balance and drift. a.mu.Lock() + defer a.mu.Unlock() + + // Update balance and drift. delta := new(big.Int).Sub(balance, a.acc.Balance) balanceBefore := a.acc.Balance.String() driftBefore := a.acc.Drift.String() @@ -532,7 +539,6 @@ func (a *Account) setBalance(balance *big.Int) { a.acc.CleanShutdown = true a.acc.RequiresSync = false // resetting the balance resets the sync field balanceAfter := a.acc.Balance.String() - a.mu.Unlock() // Log resets. a.logger.Infow("account balance was reset", From 0845ed25e8579de80d54c06231ec49a9674ced3a Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 10:58:09 +0200 Subject: [PATCH 13/41] worker: fix TestUploadSingleSectorSlowHosts --- autopilot/workerpool.go | 2 - internal/test/e2e/gouging_test.go | 99 +++++++++++++++---------------- worker/client/rhp.go | 11 ---- worker/mocks_test.go | 4 +- worker/worker.go | 26 -------- 5 files changed, 50 insertions(+), 92 deletions(-) diff --git a/autopilot/workerpool.go b/autopilot/workerpool.go index acc6d22e2..1f23a3cb6 100644 --- a/autopilot/workerpool.go +++ b/autopilot/workerpool.go @@ -19,12 +19,10 @@ type Worker interface { MigrateSlab(ctx context.Context, s object.Slab, set string) (api.MigrateSlabResponse, error) RHPBroadcast(ctx context.Context, fcid types.FileContractID) (err error) - RHPFund(ctx context.Context, contractID types.FileContractID, hostKey types.PublicKey, hostIP, siamuxAddr string, balance types.Currency) (err error) RHPPriceTable(ctx context.Context, hostKey types.PublicKey, siamuxAddr string, timeout time.Duration) (api.HostPriceTable, error) RHPPruneContract(ctx context.Context, fcid types.FileContractID, timeout time.Duration) (pruned, remaining uint64, err error) RHPRenew(ctx context.Context, fcid types.FileContractID, endHeight uint64, hk types.PublicKey, hostIP string, hostAddress, renterAddress types.Address, renterFunds, minNewCollateral, maxFundAmount types.Currency, expectedStorage, windowSize uint64) (api.RHPRenewResponse, error) RHPScan(ctx context.Context, hostKey types.PublicKey, hostIP string, timeout time.Duration) (api.RHPScanResponse, error) - RHPSync(ctx context.Context, contractID types.FileContractID, hostKey types.PublicKey, hostIP, siamuxAddr string) (err error) } // workerPool contains all workers known to the autopilot. Users can call diff --git a/internal/test/e2e/gouging_test.go b/internal/test/e2e/gouging_test.go index 5be1784cb..5d8b8bb80 100644 --- a/internal/test/e2e/gouging_test.go +++ b/internal/test/e2e/gouging_test.go @@ -13,7 +13,6 @@ import ( "go.sia.tech/core/types" "go.sia.tech/renterd/api" "go.sia.tech/renterd/internal/test" - "go.uber.org/zap/zapcore" "lukechampine.com/frand" ) @@ -139,55 +138,55 @@ func TestGouging(t *testing.T) { // TestAccountFunding is a regression tests that verify we can fund an account // even if the host is considered gouging, this protects us from not being able // to download from certain critical hosts when we migrate away from them. -func TestAccountFunding(t *testing.T) { - if testing.Short() { - t.SkipNow() - } - - // run without autopilot - opts := clusterOptsDefault - opts.skipRunningAutopilot = true - opts.logger = newTestLoggerCustom(zapcore.ErrorLevel) - - // create a new test cluster - cluster := newTestCluster(t, opts) - defer cluster.Shutdown() - - // convenience variables - b := cluster.Bus - w := cluster.Worker - tt := cluster.tt - - // add a host - hosts := cluster.AddHosts(1) - h, err := b.Host(context.Background(), hosts[0].PublicKey()) - tt.OK(err) - - // scan the host - _, err = w.RHPScan(context.Background(), h.PublicKey, h.NetAddress, 10*time.Second) - tt.OK(err) - - // manually form a contract with the host - cs, _ := b.ConsensusState(context.Background()) - wallet, _ := b.Wallet(context.Background()) - endHeight := cs.BlockHeight + test.AutopilotConfig.Contracts.Period + test.AutopilotConfig.Contracts.RenewWindow - c, err := b.FormContract(context.Background(), wallet.Address, types.Siacoins(1), h.PublicKey, h.NetAddress, types.Siacoins(1), endHeight) - tt.OK(err) - - // fund the account - tt.OK(w.RHPFund(context.Background(), c.ID, c.HostKey, c.HostIP, c.SiamuxAddr, types.Siacoins(1).Div64(2))) - - // update host so it's gouging - settings := hosts[0].settings.Settings() - settings.StoragePrice = types.Siacoins(1) - tt.OK(hosts[0].UpdateSettings(settings)) - - // ensure the price table expires so the worker is forced to fetch it - time.Sleep(defaultHostSettings.PriceTableValidity) - - // fund the account again - tt.OK(w.RHPFund(context.Background(), c.ID, c.HostKey, c.HostIP, c.SiamuxAddr, types.Siacoins(1))) -} +// func TestAccountFunding(t *testing.T) { +// if testing.Short() { +// t.SkipNow() +// } +// +// // run without autopilot +// opts := clusterOptsDefault +// opts.skipRunningAutopilot = true +// opts.logger = newTestLoggerCustom(zapcore.ErrorLevel) +// +// // create a new test cluster +// cluster := newTestCluster(t, opts) +// defer cluster.Shutdown() +// +// // convenience variables +// b := cluster.Bus +// w := cluster.Worker +// tt := cluster.tt +// +// // add a host +// hosts := cluster.AddHosts(1) +// h, err := b.Host(context.Background(), hosts[0].PublicKey()) +// tt.OK(err) +// +// // scan the host +// _, err = w.RHPScan(context.Background(), h.PublicKey, h.NetAddress, 10*time.Second) +// tt.OK(err) +// +// // manually form a contract with the host +// cs, _ := b.ConsensusState(context.Background()) +// wallet, _ := b.Wallet(context.Background()) +// endHeight := cs.BlockHeight + test.AutopilotConfig.Contracts.Period + test.AutopilotConfig.Contracts.RenewWindow +// c, err := b.FormContract(context.Background(), wallet.Address, types.Siacoins(1), h.PublicKey, h.NetAddress, types.Siacoins(1), endHeight) +// tt.OK(err) +// +// // fund the account +// tt.OK(w.RHPFund(context.Background(), c.ID, c.HostKey, c.HostIP, c.SiamuxAddr, types.Siacoins(1).Div64(2))) +// +// // update host so it's gouging +// settings := hosts[0].settings.Settings() +// settings.StoragePrice = types.Siacoins(1) +// tt.OK(hosts[0].UpdateSettings(settings)) +// +// // ensure the price table expires so the worker is forced to fetch it +// time.Sleep(defaultHostSettings.PriceTableValidity) +// +// // fund the account again +// tt.OK(w.RHPFund(context.Background(), c.ID, c.HostKey, c.HostIP, c.SiamuxAddr, types.Siacoins(1))) +// } func TestHostMinVersion(t *testing.T) { if testing.Short() { diff --git a/worker/client/rhp.go b/worker/client/rhp.go index 65b939f47..6c31bf882 100644 --- a/worker/client/rhp.go +++ b/worker/client/rhp.go @@ -89,14 +89,3 @@ func (c *Client) RHPScan(ctx context.Context, hostKey types.PublicKey, hostIP st }, &resp) return } - -// RHPSync funds an ephemeral account using the supplied contract. -func (c *Client) RHPSync(ctx context.Context, contractID types.FileContractID, hostKey types.PublicKey, hostIP, siamuxAddr string) (err error) { - req := api.RHPSyncRequest{ - ContractID: contractID, - HostKey: hostKey, - SiamuxAddr: siamuxAddr, - } - err = c.c.WithContext(ctx).POST("/rhp/sync", req, nil) - return -} diff --git a/worker/mocks_test.go b/worker/mocks_test.go index 79824ddce..5da0dcc5a 100644 --- a/worker/mocks_test.go +++ b/worker/mocks_test.go @@ -134,13 +134,12 @@ func newContractLockerMock() *contractLockerMock { func (cs *contractLockerMock) AcquireContract(_ context.Context, fcid types.FileContractID, _ int, _ time.Duration) (uint64, error) { cs.mu.Lock() - defer cs.mu.Unlock() - lock, exists := cs.locks[fcid] if !exists { cs.locks[fcid] = new(sync.Mutex) lock = cs.locks[fcid] } + cs.mu.Unlock() lock.Lock() return 0, nil @@ -151,7 +150,6 @@ func (cs *contractLockerMock) ReleaseContract(_ context.Context, fcid types.File defer cs.mu.Unlock() cs.locks[fcid].Unlock() - delete(cs.locks, fcid) return nil } diff --git a/worker/worker.go b/worker/worker.go index 5ee311343..e5f04ae5b 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -592,30 +592,6 @@ func (w *Worker) rhpRenewHandler(jc jape.Context) { }) } -func (w *Worker) rhpFundHandler(jc jape.Context) { - ctx := jc.Request.Context() - - // decode request - var rfr api.RHPFundRequest - if jc.Decode(&rfr) != nil { - return - } else if jc.Check("failed to fund account", w.FundAccount(ctx, rfr.ContractID, rfr.HostKey, rfr.SiamuxAddr, rfr.Balance)) != nil { - return - } -} - -func (w *Worker) rhpSyncHandler(jc jape.Context) { - ctx := jc.Request.Context() - - // decode the request - var rsr api.RHPSyncRequest - if jc.Decode(&rsr) != nil { - return - } else if jc.Check("failed to sync account", w.SyncAccount(ctx, rsr.ContractID, rsr.HostKey, rsr.SiamuxAddr)) != nil { - return - } -} - func (w *Worker) slabMigrateHandler(jc jape.Context) { ctx := jc.Request.Context() @@ -1234,8 +1210,6 @@ func (w *Worker) Handler() http.Handler { "GET /rhp/contract/:id/roots": w.rhpContractRootsHandlerGET, "POST /rhp/scan": w.rhpScanHandler, "POST /rhp/renew": w.rhpRenewHandler, - "POST /rhp/fund": w.rhpFundHandler, - "POST /rhp/sync": w.rhpSyncHandler, "POST /rhp/pricetable": w.rhpPriceTableHandler, "GET /stats/downloads": w.downloadsStatsHandlerGET, From e97d00734b1f64f4e6d7eb7d170d7077097239fc Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Wed, 21 Aug 2024 15:29:59 +0200 Subject: [PATCH 14/41] bus: fix 'owner' --- bus/routes.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bus/routes.go b/bus/routes.go index f564b822b..279120909 100644 --- a/bus/routes.go +++ b/bus/routes.go @@ -1728,10 +1728,10 @@ func (b *Bus) accountsHandlerGET(jc jape.Context) { func (b *Bus) accountsHandlerPOST(jc jape.Context) { var req api.AccountsSaveRequest - if req.Owner == "" { - jc.Error(errors.New("owner is required"), http.StatusBadRequest) + if jc.Decode(&req) != nil { return - } else if jc.Decode(&req) != nil { + } else if req.Owner == "" { + jc.Error(errors.New("owner is required"), http.StatusBadRequest) return } else if b.accounts.SaveAccounts(jc.Request.Context(), req.Owner, req.Accounts) != nil { return From 6e7d234fb2e7711ba5165d72ef9fdc6234ada9d3 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Wed, 21 Aug 2024 16:43:16 +0200 Subject: [PATCH 15/41] bus: remove unused request type --- api/bus.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/api/bus.go b/api/bus.go index a124f50e5..86c3f5da4 100644 --- a/api/bus.go +++ b/api/bus.go @@ -51,10 +51,6 @@ type ( SetUnclean bool `json:"setUnclean"` } - AccountsUncleanRequest struct { - Owner string `json:"owner"` - } - // BusStateResponse is the response type for the /bus/state endpoint. BusStateResponse struct { StartTime TimeRFC3339 `json:"startTime"` From 65299ee794046fdfe80706fd591d1ad9c75a650b Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Wed, 21 Aug 2024 16:59:59 +0200 Subject: [PATCH 16/41] worker: deep-copy in Account --- internal/worker/accounts.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/worker/accounts.go b/internal/worker/accounts.go index 7fccf0bf1..2336cd155 100644 --- a/internal/worker/accounts.go +++ b/internal/worker/accounts.go @@ -120,7 +120,14 @@ func (a *AccountMgr) Account(hostKey types.PublicKey) api.Account { acc := a.account(hostKey) acc.mu.Lock() defer acc.mu.Unlock() - return acc.acc + return api.Account{ + ID: acc.acc.ID, + CleanShutdown: acc.acc.CleanShutdown, + HostKey: acc.acc.HostKey, + Balance: new(big.Int).Set(acc.acc.Balance), + Drift: new(big.Int).Set(acc.acc.Drift), + RequiresSync: acc.acc.RequiresSync, + } } // Accounts returns all accounts. From 96275cb271329813cfc125ed69fec72d8bf741a1 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Wed, 21 Aug 2024 17:14:31 +0200 Subject: [PATCH 17/41] worker: convert helper --- internal/worker/accounts.go | 40 +++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/internal/worker/accounts.go b/internal/worker/accounts.go index 2336cd155..f9f9c3a2b 100644 --- a/internal/worker/accounts.go +++ b/internal/worker/accounts.go @@ -118,16 +118,7 @@ func NewAccountManager(key types.PrivateKey, owner string, w AccountMgrWorker, c // Account returns the account with the given id. func (a *AccountMgr) Account(hostKey types.PublicKey) api.Account { acc := a.account(hostKey) - acc.mu.Lock() - defer acc.mu.Unlock() - return api.Account{ - ID: acc.acc.ID, - CleanShutdown: acc.acc.CleanShutdown, - HostKey: acc.acc.HostKey, - Balance: new(big.Int).Set(acc.acc.Balance), - Drift: new(big.Int).Set(acc.acc.Drift), - RequiresSync: acc.acc.RequiresSync, - } + return acc.convert() } // Accounts returns all accounts. @@ -136,9 +127,7 @@ func (a *AccountMgr) Accounts() []api.Account { defer a.mu.Unlock() accounts := make([]api.Account, 0, len(a.byID)) for _, acc := range a.byID { - acc.mu.Lock() - accounts = append(accounts, acc.acc) - acc.mu.Unlock() + accounts = append(accounts, acc.convert()) } return accounts } @@ -157,12 +146,6 @@ func (a *AccountMgr) ResetDrift(id rhpv3.Account) error { return nil } -func (a *Account) resetDrift() { - a.mu.Lock() - a.acc.Drift.SetInt64(0) - a.mu.Unlock() -} - func (a *AccountMgr) Shutdown(ctx context.Context) error { accounts := a.Accounts() err := a.s.UpdateAccounts(ctx, a.owner, accounts, false) @@ -504,6 +487,25 @@ func (a *Account) addAmount(amt *big.Int) { } } +func (a *Account) convert() api.Account { + a.mu.Lock() + defer a.mu.Unlock() + return api.Account{ + ID: a.acc.ID, + CleanShutdown: a.acc.CleanShutdown, + HostKey: a.acc.HostKey, + Balance: new(big.Int).Set(a.acc.Balance), + Drift: new(big.Int).Set(a.acc.Drift), + RequiresSync: a.acc.RequiresSync, + } +} + +func (a *Account) resetDrift() { + a.mu.Lock() + a.acc.Drift.SetInt64(0) + a.mu.Unlock() +} + // scheduleSync sets the requiresSync flag of an account. func (a *Account) scheduleSync() { a.mu.Lock() From 42cad7434f9236e5d779fb31f385e21f8b77b793 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 10:58:43 +0200 Subject: [PATCH 18/41] worker: TestEphemeralAccounts --- internal/test/e2e/cluster_test.go | 165 +++++++++--------------------- internal/test/e2e/gouging_test.go | 53 ---------- internal/worker/accounts.go | 10 +- worker/worker.go | 3 + 4 files changed, 57 insertions(+), 174 deletions(-) diff --git a/internal/test/e2e/cluster_test.go b/internal/test/e2e/cluster_test.go index fae83430a..ad09486b5 100644 --- a/internal/test/e2e/cluster_test.go +++ b/internal/test/e2e/cluster_test.go @@ -18,6 +18,7 @@ import ( "github.com/google/go-cmp/cmp" rhpv2 "go.sia.tech/core/rhp/v2" + rhpv3 "go.sia.tech/core/rhp/v3" "go.sia.tech/core/types" "go.sia.tech/coreutils/wallet" "go.sia.tech/renterd/alerts" @@ -1122,123 +1123,53 @@ func TestContractApplyChainUpdates(t *testing.T) { } // TestEphemeralAccounts tests the use of ephemeral accounts. -// func TestEphemeralAccounts(t *testing.T) { -// if testing.Short() { -// t.SkipNow() -// } -// -// // Create cluster -// cluster := newTestCluster(t, testClusterOptions{hosts: 1}) -// defer cluster.Shutdown() -// tt := cluster.tt -// -// // Shut down the autopilot to prevent it from interfering. -// cluster.ShutdownAutopilot(context.Background()) -// -// // Wait for contract and accounts. -// contract := cluster.WaitForContracts()[0] -// accounts := cluster.WaitForAccounts() -// -// // Shut down the autopilot to prevent it from interfering with the test. -// cluster.ShutdownAutopilot(context.Background()) -// -// // Newly created accounts are !cleanShutdown. Simulate a sync to change -// // that. -// for _, acc := range accounts { -// if acc.CleanShutdown { -// t.Fatal("new account should indicate an unclean shutdown") -// } else if acc.RequiresSync { -// t.Fatal("new account should not require a sync") -// } -// if err := cluster.Bus.SetBalance(context.Background(), acc.ID, acc.HostKey, types.Siacoins(1).Big()); err != nil { -// t.Fatal(err) -// } -// } -// -// // Fetch accounts again. -// accounts = cluster.Accounts() -// -// acc := accounts[0] -// if acc.Balance.Cmp(types.Siacoins(1).Big()) < 0 { -// t.Fatalf("wrong balance %v", acc.Balance) -// } -// if acc.ID == (rhpv3.Account{}) { -// t.Fatal("account id not set") -// } -// host := cluster.hosts[0] -// if acc.HostKey != types.PublicKey(host.PublicKey()) { -// t.Fatal("wrong host") -// } -// if !acc.CleanShutdown { -// t.Fatal("account should indicate a clean shutdown") -// } -// -// // Fetch account from bus directly. -// busAccounts := cluster.Accounts() -// if len(busAccounts) != 1 { -// t.Fatal("expected one account but got", len(busAccounts)) -// } -// busAcc := busAccounts[0] -// if !reflect.DeepEqual(busAcc, acc) { -// t.Fatal("bus account doesn't match worker account") -// } -// -// // Check that the spending was recorded for the contract. The recorded -// // spending should be > the fundAmt since it consists of the fundAmt plus -// // fee. -// fundAmt := types.Siacoins(1) -// tt.Retry(10, testBusFlushInterval, func() error { -// cm, err := cluster.Bus.Contract(context.Background(), contract.ID) -// tt.OK(err) -// -// if cm.Spending.FundAccount.Cmp(fundAmt) <= 0 { -// return fmt.Errorf("invalid spending reported: %v > %v", fundAmt.String(), cm.Spending.FundAccount.String()) -// } -// return nil -// }) -// -// // Update the balance to create some drift. -// newBalance := fundAmt.Div64(2) -// newDrift := new(big.Int).Sub(newBalance.Big(), fundAmt.Big()) -// if err := cluster.Bus.SetBalance(context.Background(), busAcc.ID, acc.HostKey, newBalance.Big()); err != nil { -// t.Fatal(err) -// } -// busAccounts = cluster.Accounts() -// busAcc = busAccounts[0] -// maxNewDrift := newDrift.Add(newDrift, types.NewCurrency64(2).Big()) // forgive 2H -// if busAcc.Drift.Cmp(maxNewDrift) > 0 { -// t.Fatalf("drift was %v but should be %v", busAcc.Drift, maxNewDrift) -// } -// -// // Reboot cluster. -// cluster2 := cluster.Reboot(t) -// defer cluster2.Shutdown() -// -// // Check that accounts were loaded from the bus. -// accounts2 := cluster2.Accounts() -// for _, acc := range accounts2 { -// if acc.Balance.Cmp(big.NewInt(0)) == 0 { -// t.Fatal("account balance wasn't loaded") -// } else if acc.Drift.Cmp(big.NewInt(0)) == 0 { -// t.Fatal("account drift wasn't loaded") -// } else if !acc.CleanShutdown { -// t.Fatal("account should indicate a clean shutdown") -// } -// } -// -// // Reset drift again. -// if err := cluster2.Bus.ResetDrift(context.Background(), acc.ID); err != nil { -// t.Fatal(err) -// } -// accounts2 = cluster2.Accounts() -// if accounts2[0].Drift.Cmp(new(big.Int)) != 0 { -// t.Fatal("drift wasn't reset", accounts2[0].Drift.String()) -// } -// accounts2 = cluster2.Accounts() -// if accounts2[0].Drift.Cmp(new(big.Int)) != 0 { -// t.Fatal("drift wasn't reset", accounts2[0].Drift.String()) -// } -//} +func TestEphemeralAccounts(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + + // Create cluster + cluster := newTestCluster(t, testClusterOptions{hosts: 1}) + defer cluster.Shutdown() + tt := cluster.tt + + // Shut down the autopilot to prevent it from interfering. + cluster.ShutdownAutopilot(context.Background()) + + // Accounts should exist for the host + accounts := cluster.Accounts() + + acc := accounts[0] + host := cluster.hosts[0] + if acc.Balance.Cmp(types.Siacoins(1).Big()) < 0 { + t.Fatalf("wrong balance %v", acc.Balance) + } else if acc.ID == (rhpv3.Account{}) { + t.Fatal("account id not set") + } else if acc.HostKey != types.PublicKey(host.PublicKey()) { + t.Fatal("wrong host") + } else if !acc.CleanShutdown { + t.Fatal("account should indicate a clean shutdown") + } + + // Check that the spending was recorded for the contract. The recorded + // spending should be > the fundAmt since it consists of the fundAmt plus + // fee. + contracts, err := cluster.Bus.Contracts(context.Background(), api.ContractsOpts{}) + tt.OK(err) + if len(contracts) != 1 { + t.Fatalf("expected 1 contract, got %v", len(contracts)) + } + tt.Retry(10, testBusFlushInterval, func() error { + cm, err := cluster.Bus.Contract(context.Background(), contracts[0].ID) + tt.OK(err) + + fundAmt := types.Siacoins(1) + if cm.Spending.FundAccount.Cmp(fundAmt) <= 0 { + return fmt.Errorf("invalid spending reported: %v > %v", fundAmt.String(), cm.Spending.FundAccount.String()) + } + return nil + }) +} // TestParallelUpload tests uploading multiple files in parallel. func TestParallelUpload(t *testing.T) { diff --git a/internal/test/e2e/gouging_test.go b/internal/test/e2e/gouging_test.go index 5d8b8bb80..851362489 100644 --- a/internal/test/e2e/gouging_test.go +++ b/internal/test/e2e/gouging_test.go @@ -135,59 +135,6 @@ func TestGouging(t *testing.T) { }) } -// TestAccountFunding is a regression tests that verify we can fund an account -// even if the host is considered gouging, this protects us from not being able -// to download from certain critical hosts when we migrate away from them. -// func TestAccountFunding(t *testing.T) { -// if testing.Short() { -// t.SkipNow() -// } -// -// // run without autopilot -// opts := clusterOptsDefault -// opts.skipRunningAutopilot = true -// opts.logger = newTestLoggerCustom(zapcore.ErrorLevel) -// -// // create a new test cluster -// cluster := newTestCluster(t, opts) -// defer cluster.Shutdown() -// -// // convenience variables -// b := cluster.Bus -// w := cluster.Worker -// tt := cluster.tt -// -// // add a host -// hosts := cluster.AddHosts(1) -// h, err := b.Host(context.Background(), hosts[0].PublicKey()) -// tt.OK(err) -// -// // scan the host -// _, err = w.RHPScan(context.Background(), h.PublicKey, h.NetAddress, 10*time.Second) -// tt.OK(err) -// -// // manually form a contract with the host -// cs, _ := b.ConsensusState(context.Background()) -// wallet, _ := b.Wallet(context.Background()) -// endHeight := cs.BlockHeight + test.AutopilotConfig.Contracts.Period + test.AutopilotConfig.Contracts.RenewWindow -// c, err := b.FormContract(context.Background(), wallet.Address, types.Siacoins(1), h.PublicKey, h.NetAddress, types.Siacoins(1), endHeight) -// tt.OK(err) -// -// // fund the account -// tt.OK(w.RHPFund(context.Background(), c.ID, c.HostKey, c.HostIP, c.SiamuxAddr, types.Siacoins(1).Div64(2))) -// -// // update host so it's gouging -// settings := hosts[0].settings.Settings() -// settings.StoragePrice = types.Siacoins(1) -// tt.OK(hosts[0].UpdateSettings(settings)) -// -// // ensure the price table expires so the worker is forced to fetch it -// time.Sleep(defaultHostSettings.PriceTableValidity) -// -// // fund the account again -// tt.OK(w.RHPFund(context.Background(), c.ID, c.HostKey, c.HostIP, c.SiamuxAddr, types.Siacoins(1))) -// } - func TestHostMinVersion(t *testing.T) { if testing.Short() { t.SkipNow() diff --git a/internal/worker/accounts.go b/internal/worker/accounts.go index f9f9c3a2b..5b23ccbbe 100644 --- a/internal/worker/accounts.go +++ b/internal/worker/accounts.go @@ -180,7 +180,7 @@ func (a *AccountMgr) account(hk types.PublicKey) *Account { HostKey: hk, Balance: big.NewInt(0), Drift: big.NewInt(0), - RequiresSync: false, + RequiresSync: true, // force sync on new account }, } a.byID[accID] = acc @@ -231,10 +231,12 @@ func (a *AccountMgr) run() { a.logger.Errorf("account key derivation mismatch %v != %v", accKey.PublicKey(), acc.ID) continue } + acc.RequiresSync = true // force sync on reboot account := &Account{ - acc: acc, - key: accKey, - logger: a.logger.Named(acc.ID.String()), + acc: acc, + key: accKey, + logger: a.logger.Named(acc.ID.String()), + requiresSyncTime: time.Now(), } accounts[account.acc.ID] = account } diff --git a/worker/worker.go b/worker/worker.go index e5f04ae5b..623e4b11f 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -1249,6 +1249,9 @@ func (w *Worker) Shutdown(ctx context.Context) error { w.downloadManager.Stop() w.uploadManager.Stop() + // stop account manager + w.accounts.Shutdown(ctx) + // stop recorders w.contractSpendingRecorder.Stop(ctx) From 585787686e5515250820747269491f9ccdedcd7c Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 10:07:37 +0200 Subject: [PATCH 19/41] worker: add accounts_test.go --- internal/worker/accounts_test.go | 127 +++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 internal/worker/accounts_test.go diff --git a/internal/worker/accounts_test.go b/internal/worker/accounts_test.go new file mode 100644 index 000000000..224b0ff2d --- /dev/null +++ b/internal/worker/accounts_test.go @@ -0,0 +1,127 @@ +package worker + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "go.sia.tech/core/types" + "go.sia.tech/renterd/api" + "go.uber.org/zap" +) + +type mockAccountMgrBackend struct { + contracts []api.ContractMetadata +} + +func (b *mockAccountMgrBackend) FundAccount(ctx context.Context, fcid types.FileContractID, hk types.PublicKey, siamuxAddr string, balance types.Currency) error { + return nil +} +func (b *mockAccountMgrBackend) SyncAccount(ctx context.Context, fcid types.FileContractID, hk types.PublicKey, siamuxAddr string) error { + return nil +} +func (b *mockAccountMgrBackend) Accounts(context.Context, string) ([]api.Account, error) { + return []api.Account{}, nil +} +func (b *mockAccountMgrBackend) UpdateAccounts(context.Context, string, []api.Account, bool) error { + return nil +} +func (b *mockAccountMgrBackend) ConsensusState(ctx context.Context) (api.ConsensusState, error) { + return api.ConsensusState{}, nil +} +func (b *mockAccountMgrBackend) DownloadContracts(ctx context.Context) ([]api.ContractMetadata, error) { + return nil, nil +} + +func TestAccounts(t *testing.T) { + // create a manager with an account for a single host + hk := types.PublicKey{1} + b := &mockAccountMgrBackend{ + contracts: []api.ContractMetadata{ + { + ID: types.FileContractID{1}, + HostKey: hk, + }, + }, + } + mgr, err := NewAccountManager(types.GeneratePrivateKey(), "test", b, b, b, b, time.Second, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + + // create account + account := mgr.ForHost(hk) + + // assert account exists + accounts := mgr.Accounts() + if len(accounts) != 1 { + t.Fatalf("expected 1 account but got %v", len(accounts)) + } + + comparer := cmp.Comparer(func(i1, i2 *big.Int) bool { + return i1.Cmp(i2) == 0 + }) + + // Newly created accounts are !cleanShutdown. Simulate a sync to change + // that. + for _, acc := range accounts { + if expected := (api.Account{ + CleanShutdown: false, + RequiresSync: false, + ID: account.ID(), + HostKey: hk, + Balance: types.ZeroCurrency.Big(), + Drift: types.ZeroCurrency.Big(), + }); !cmp.Equal(acc, expected, comparer) { + t.Fatal("account doesn't match expectation", cmp.Diff(acc, expected, comparer)) + } + } + + // set balance to 1SC + account.setBalance(types.Siacoins(1).Big()) + + acc := mgr.Account(hk) + if expected := (api.Account{ + CleanShutdown: true, + RequiresSync: false, + ID: account.ID(), + HostKey: hk, + Balance: types.Siacoins(1).Big(), + Drift: types.ZeroCurrency.Big(), + }); !cmp.Equal(acc, expected, comparer) { + t.Fatal("account doesn't match expectation", cmp.Diff(acc, expected, comparer)) + } + + // schedule a sync + account.scheduleSync() + + acc = mgr.Account(hk) + if expected := (api.Account{ + CleanShutdown: true, + RequiresSync: true, + ID: account.ID(), + HostKey: hk, + Balance: types.Siacoins(1).Big(), + Drift: types.ZeroCurrency.Big(), + }); !cmp.Equal(acc, expected, comparer) { + t.Fatal("account doesn't match expectation", cmp.Diff(acc, expected, comparer)) + } + + // update the balance to create some drift, sync should be reset + newBalance := types.Siacoins(1).Div64(2).Big() + newDrift := new(big.Int).Neg(newBalance) + account.setBalance(newBalance) + acc = mgr.Account(hk) + if expected := (api.Account{ + CleanShutdown: true, + RequiresSync: false, + ID: account.ID(), + HostKey: hk, + Balance: newBalance, + Drift: newDrift, + }); !cmp.Equal(acc, expected, comparer) { + t.Fatal("account doesn't match expectation", cmp.Diff(acc, expected, comparer)) + } +} From baf93de5ec302cbaeba6877cb300739a5522323b Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 11:11:59 +0200 Subject: [PATCH 20/41] fix jape --- api/worker.go | 8 -------- worker/client/rhp.go | 12 ------------ 2 files changed, 20 deletions(-) diff --git a/api/worker.go b/api/worker.go index 9bce3386f..b8b2b9a93 100644 --- a/api/worker.go +++ b/api/worker.go @@ -87,14 +87,6 @@ type ( TransactionSet []types.Transaction `json:"transactionSet"` } - // RHPFundRequest is the request type for the /rhp/fund endpoint. - RHPFundRequest struct { - ContractID types.FileContractID `json:"contractID"` - HostKey types.PublicKey `json:"hostKey"` - SiamuxAddr string `json:"siamuxAddr"` - Balance types.Currency `json:"balance"` - } - // RHPPruneContractRequest is the request type for the /rhp/contract/:id/prune // endpoint. RHPPruneContractRequest struct { diff --git a/worker/client/rhp.go b/worker/client/rhp.go index 6c31bf882..47c177e61 100644 --- a/worker/client/rhp.go +++ b/worker/client/rhp.go @@ -22,18 +22,6 @@ func (c *Client) RHPContractRoots(ctx context.Context, contractID types.FileCont return } -// RHPFund funds an ephemeral account using the supplied contract. -func (c *Client) RHPFund(ctx context.Context, contractID types.FileContractID, hostKey types.PublicKey, hostIP, siamuxAddr string, balance types.Currency) (err error) { - req := api.RHPFundRequest{ - ContractID: contractID, - HostKey: hostKey, - SiamuxAddr: siamuxAddr, - Balance: balance, - } - err = c.c.WithContext(ctx).POST("/rhp/fund", req, nil) - return -} - // RHPPriceTable fetches a price table for a host. func (c *Client) RHPPriceTable(ctx context.Context, hostKey types.PublicKey, siamuxAddr string, timeout time.Duration) (pt api.HostPriceTable, err error) { req := api.RHPPriceTableRequest{ From 712cfa1bcd123f7e311301df081b21d17d7bccb0 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 11:20:33 +0200 Subject: [PATCH 21/41] worker: fix TestAccounts --- internal/worker/accounts_test.go | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/internal/worker/accounts_test.go b/internal/worker/accounts_test.go index 224b0ff2d..65743dd0d 100644 --- a/internal/worker/accounts_test.go +++ b/internal/worker/accounts_test.go @@ -64,12 +64,12 @@ func TestAccounts(t *testing.T) { return i1.Cmp(i2) == 0 }) - // Newly created accounts are !cleanShutdown. Simulate a sync to change - // that. + // Newly created accounts are !cleanShutdown and require a sync. Simulate a + // sync to change that. for _, acc := range accounts { if expected := (api.Account{ CleanShutdown: false, - RequiresSync: false, + RequiresSync: true, ID: account.ID(), HostKey: hk, Balance: types.ZeroCurrency.Big(), @@ -79,10 +79,25 @@ func TestAccounts(t *testing.T) { } } - // set balance to 1SC - account.setBalance(types.Siacoins(1).Big()) + // set balance to 0SC to simulate a sync + account.setBalance(types.ZeroCurrency.Big()) acc := mgr.Account(hk) + if expected := (api.Account{ + CleanShutdown: true, + RequiresSync: false, + ID: account.ID(), + HostKey: hk, + Balance: types.ZeroCurrency.Big(), + Drift: types.ZeroCurrency.Big(), + }); !cmp.Equal(acc, expected, comparer) { + t.Fatal("account doesn't match expectation", cmp.Diff(acc, expected, comparer)) + } + + // fund with 1 SC + account.addAmount(types.Siacoins(1).Big()) + + acc = mgr.Account(hk) if expected := (api.Account{ CleanShutdown: true, RequiresSync: false, From 6c61aa931288c6da3837cc3ae9aa0dac6897af79 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 11:34:53 +0200 Subject: [PATCH 22/41] e2e: fix TestEphemeralAccounts --- internal/test/e2e/cluster_test.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/test/e2e/cluster_test.go b/internal/test/e2e/cluster_test.go index fb9217cc0..255999911 100644 --- a/internal/test/e2e/cluster_test.go +++ b/internal/test/e2e/cluster_test.go @@ -1157,9 +1157,7 @@ func TestEphemeralAccounts(t *testing.T) { // manually form a contract with the host cs, _ := b.ConsensusState(context.Background()) wallet, _ := b.Wallet(context.Background()) - rev, _, err := w.RHPForm(context.Background(), cs.BlockHeight+test.AutopilotConfig.Contracts.Period+test.AutopilotConfig.Contracts.RenewWindow, h.PublicKey, h.NetAddress, wallet.Address, types.Siacoins(10), types.Siacoins(1)) - tt.OK(err) - c, err := b.AddContract(context.Background(), rev, rev.Revision.MissedHostPayout().Sub(types.Siacoins(1)), types.Siacoins(1), cs.BlockHeight, api.ContractStatePending) + c, err := b.FormContract(context.Background(), wallet.Address, types.Siacoins(2), h.PublicKey, h.NetAddress, types.Siacoins(1), cs.BlockHeight+10) tt.OK(err) tt.OK(b.SetContractSet(context.Background(), test.ContractSet, []types.FileContractID{c.ID})) From 7fde826eab843271d989e8c46772de721047c17c Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 10:42:42 +0200 Subject: [PATCH 23/41] bus: make sure formed contracts are added to the worker cache --- bus/bus.go | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++ bus/routes.go | 62 ++---------------------------------------------- 2 files changed, 67 insertions(+), 60 deletions(-) diff --git a/bus/bus.go b/bus/bus.go index c4deb1c98..c5ae1113e 100644 --- a/bus/bus.go +++ b/bus/bus.go @@ -542,6 +542,71 @@ func (b *Bus) Shutdown(ctx context.Context) error { ) } +func (b *Bus) addContract(ctx context.Context, rev rhpv2.ContractRevision, contractPrice, totalCost types.Currency, startHeight uint64, state string) (api.ContractMetadata, error) { + c, err := b.ms.AddContract(ctx, rev, contractPrice, totalCost, startHeight, state) + if err != nil { + return api.ContractMetadata{}, err + } + + b.broadcastAction(webhooks.Event{ + Module: api.ModuleContract, + Event: api.EventAdd, + Payload: api.EventContractAdd{ + Added: c, + Timestamp: time.Now().UTC(), + }, + }) + return c, nil +} + +func (b *Bus) isPassedV2AllowHeight() bool { + cs := b.cm.TipState() + return cs.Index.Height >= cs.Network.HardforkV2.AllowHeight +} + +func (b *Bus) formContract(ctx context.Context, hostSettings rhpv2.HostSettings, renterAddress types.Address, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostIP string, endHeight uint64) (rhpv2.ContractRevision, error) { + // derive the renter key + renterKey := b.deriveRenterKey(hostKey) + + // prepare the transaction + cs := b.cm.TipState() + fc := rhpv2.PrepareContractFormation(renterKey.PublicKey(), hostKey, renterFunds, hostCollateral, endHeight, hostSettings, renterAddress) + txn := types.Transaction{FileContracts: []types.FileContract{fc}} + + // calculate the miner fee + fee := b.cm.RecommendedFee().Mul64(cs.TransactionWeight(txn)) + txn.MinerFees = []types.Currency{fee} + + // fund the transaction + cost := rhpv2.ContractFormationCost(cs, fc, hostSettings.ContractPrice).Add(fee) + toSign, err := b.w.FundTransaction(&txn, cost, true) + if err != nil { + return rhpv2.ContractRevision{}, fmt.Errorf("couldn't fund transaction: %w", err) + } + + // sign the transaction + b.w.SignTransaction(&txn, toSign, wallet.ExplicitCoveredFields(txn)) + + // form the contract + contract, txnSet, err := b.rhp2.FormContract(ctx, hostKey, hostIP, renterKey, append(b.cm.UnconfirmedParents(txn), txn)) + if err != nil { + b.w.ReleaseInputs([]types.Transaction{txn}, nil) + return rhpv2.ContractRevision{}, err + } + + // add transaction set to the pool + _, err = b.cm.AddPoolTransactions(txnSet) + if err != nil { + b.w.ReleaseInputs([]types.Transaction{txn}, nil) + return rhpv2.ContractRevision{}, fmt.Errorf("couldn't add transaction set to the pool: %w", err) + } + + // broadcast the transaction set + go b.s.BroadcastTransactionSet(txnSet) + + return contract, nil +} + // initSettings loads the default settings if the setting is not already set and // ensures the settings are valid func (b *Bus) initSettings(ctx context.Context) error { diff --git a/bus/routes.go b/bus/routes.go index a76d862e5..f020c5944 100644 --- a/bus/routes.go +++ b/bus/routes.go @@ -948,20 +948,10 @@ func (b *Bus) contractIDHandlerPOST(jc jape.Context) { return } - a, err := b.ms.AddContract(jc.Request.Context(), req.Contract, req.ContractPrice, req.TotalCost, req.StartHeight, req.State) + a, err := b.addContract(jc.Request.Context(), req.Contract, req.ContractPrice, req.TotalCost, req.StartHeight, req.State) if jc.Check("couldn't store contract", err) != nil { return } - - b.broadcastAction(webhooks.Event{ - Module: api.ModuleContract, - Event: api.EventAdd, - Payload: api.EventContractAdd{ - Added: a, - Timestamp: time.Now().UTC(), - }, - }) - jc.Encode(a) } @@ -2357,7 +2347,7 @@ func (b *Bus) contractsFormHandler(jc jape.Context) { } // store the contract - metadata, err := b.ms.AddContract( + metadata, err := b.addContract( ctx, contract, contract.Revision.MissedHostPayout().Sub(rfr.HostCollateral), @@ -2372,51 +2362,3 @@ func (b *Bus) contractsFormHandler(jc jape.Context) { // return the contract jc.Encode(metadata) } - -func (b *Bus) formContract(ctx context.Context, hostSettings rhpv2.HostSettings, renterAddress types.Address, renterFunds, hostCollateral types.Currency, hostKey types.PublicKey, hostIP string, endHeight uint64) (rhpv2.ContractRevision, error) { - // derive the renter key - renterKey := b.deriveRenterKey(hostKey) - - // prepare the transaction - cs := b.cm.TipState() - fc := rhpv2.PrepareContractFormation(renterKey.PublicKey(), hostKey, renterFunds, hostCollateral, endHeight, hostSettings, renterAddress) - txn := types.Transaction{FileContracts: []types.FileContract{fc}} - - // calculate the miner fee - fee := b.cm.RecommendedFee().Mul64(cs.TransactionWeight(txn)) - txn.MinerFees = []types.Currency{fee} - - // fund the transaction - cost := rhpv2.ContractFormationCost(cs, fc, hostSettings.ContractPrice).Add(fee) - toSign, err := b.w.FundTransaction(&txn, cost, true) - if err != nil { - return rhpv2.ContractRevision{}, fmt.Errorf("couldn't fund transaction: %w", err) - } - - // sign the transaction - b.w.SignTransaction(&txn, toSign, wallet.ExplicitCoveredFields(txn)) - - // form the contract - contract, txnSet, err := b.rhp2.FormContract(ctx, hostKey, hostIP, renterKey, append(b.cm.UnconfirmedParents(txn), txn)) - if err != nil { - b.w.ReleaseInputs([]types.Transaction{txn}, nil) - return rhpv2.ContractRevision{}, err - } - - // add transaction set to the pool - _, err = b.cm.AddPoolTransactions(txnSet) - if err != nil { - b.w.ReleaseInputs([]types.Transaction{txn}, nil) - return rhpv2.ContractRevision{}, fmt.Errorf("couldn't add transaction set to the pool: %w", err) - } - - // broadcast the transaction set - go b.s.BroadcastTransactionSet(txnSet) - - return contract, nil -} - -func (b *Bus) isPassedV2AllowHeight() bool { - cs := b.cm.TipState() - return cs.Index.Height >= cs.Network.HardforkV2.AllowHeight -} From 8cdfbe810bad5eef93935e6c12b6554a46af9625 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 14:04:21 +0200 Subject: [PATCH 24/41] autopilot: remove leftover code --- autopilot/autopilot.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/autopilot/autopilot.go b/autopilot/autopilot.go index 694006085..0c561af16 100644 --- a/autopilot/autopilot.go +++ b/autopilot/autopilot.go @@ -236,7 +236,6 @@ func (ap *Autopilot) Run() { } var forceScan bool - var launchAccountRefillsOnce sync.Once for !ap.isStopped() { ap.logger.Info("autopilot iteration starting") tickerFired := make(chan struct{}) @@ -319,13 +318,6 @@ func (ap *Autopilot) Run() { ap.m.SignalMaintenanceFinished() } - // launch account refills after successful contract maintenance. - if maintenanceSuccess { - launchAccountRefillsOnce.Do(func() { - ap.logger.Info("account refills loop launched") - }) - } - // migration ap.m.tryPerformMigrations(ap.workers) @@ -347,7 +339,6 @@ func (ap *Autopilot) Run() { case <-tickerFired: } } - return } // Shutdown shuts down the autopilot. From 1b170e96b6145e57b3e620a4afa84f40c62f4977 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 14:48:31 +0200 Subject: [PATCH 25/41] fix TestEphemeralAccountSync --- internal/test/e2e/cluster_test.go | 129 +++++++++++++++--------------- 1 file changed, 66 insertions(+), 63 deletions(-) diff --git a/internal/test/e2e/cluster_test.go b/internal/test/e2e/cluster_test.go index 90dae49d6..864297eb2 100644 --- a/internal/test/e2e/cluster_test.go +++ b/internal/test/e2e/cluster_test.go @@ -9,6 +9,8 @@ import ( "fmt" "io" "math" + "os" + "path/filepath" "reflect" "sort" "strings" @@ -1293,69 +1295,70 @@ func TestParallelDownload(t *testing.T) { // TestEphemeralAccountSync verifies that setting the requiresSync flag makes // the autopilot resync the balance between renter and host. -// func TestEphemeralAccountSync(t *testing.T) { -// if testing.Short() { -// t.SkipNow() -// } -// -// dir := t.TempDir() -// cluster := newTestCluster(t, testClusterOptions{ -// dir: dir, -// hosts: 1, -// }) -// tt := cluster.tt -// -// // Shut down the autopilot to prevent it from manipulating the account. -// cluster.ShutdownAutopilot(context.Background()) -// -// // Fetch the account balance before setting the balance -// accounts := cluster.Accounts() -// if len(accounts) != 1 || accounts[0].RequiresSync { -// t.Fatal("account shouldn't require a sync") -// } -// acc := accounts[0] -// -// // Set requiresSync flag on bus and balance to 0. -// if err := cluster.Bus.SetBalance(context.Background(), acc.ID, acc.HostKey, new(big.Int)); err != nil { -// t.Fatal(err) -// } -// if err := cluster.Bus.ScheduleSync(context.Background(), acc.ID, acc.HostKey); err != nil { -// t.Fatal(err) -// } -// accounts = cluster.Accounts() -// if len(accounts) != 1 || !accounts[0].RequiresSync { -// t.Fatal("account wasn't updated") -// } -// -// // Restart cluster to have worker fetch the account from the bus again. -// cluster2 := cluster.Reboot(t) -// defer cluster2.Shutdown() -// -// // Account should need a sync. -// account, err := cluster2.Bus.Account(context.Background(), acc.ID, acc.HostKey) -// tt.OK(err) -// if !account.RequiresSync { -// t.Fatal("flag wasn't persisted") -// } -// -// // Wait for autopilot to sync and reset flag. -// tt.Retry(100, 100*time.Millisecond, func() error { -// account, err := cluster2.Bus.Account(context.Background(), acc.ID, acc.HostKey) -// if err != nil { -// t.Fatal(err) -// } -// if account.RequiresSync { -// return errors.New("account wasn't synced") -// } -// return nil -// }) -// -// // Flag should also be reset on bus now. -// accounts = cluster2.Accounts() -// if len(accounts) != 1 || accounts[0].RequiresSync { -// t.Fatal("account wasn't updated") -// } -//} +func TestEphemeralAccountSync(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + + dir := t.TempDir() + cluster := newTestCluster(t, testClusterOptions{ + dir: dir, + hosts: 1, + }) + tt := cluster.tt + hk := cluster.hosts[0].PublicKey() + + // Fetch the account balance before setting the balance + accounts := cluster.Accounts() + if len(accounts) != 1 || accounts[0].RequiresSync { + t.Fatal("account shouldn't require a sync") + } + acc := accounts[0] + + // stop the cluster + host := cluster.hosts[0] + cluster.hosts = nil // exclude hosts from shutdown + cluster.Shutdown() + + // remove the cluster's database + tt.OK(os.Remove(filepath.Join(dir, "bus", "db", "db.sqlite"))) + + // start the cluster again + cluster = newTestCluster(t, testClusterOptions{ + dir: cluster.dir, + dbName: cluster.dbName, + logger: cluster.logger, + walletKey: &cluster.wk, + }) + cluster.hosts = append(cluster.hosts, host) + defer cluster.Shutdown() + + // connect to the host again + tt.OK(cluster.Bus.SyncerConnect(context.Background(), host.SyncerAddr())) + cluster.sync() + + // ask for the account, this should trigger its creation + tt.OKAll(cluster.Worker.Account(context.Background(), hk)) + + accounts = cluster.Accounts() + if len(accounts) != 1 || accounts[0].ID != acc.ID { + t.Fatal("account should exist") + } else if accounts[0].CleanShutdown || !accounts[0].RequiresSync { + t.Fatalf("account shouldn't be marked as clean shutdown or not require a sync, got %v", accounts[0]) + } + + tt.Retry(100, 100*time.Millisecond, func() error { + accounts = cluster.Accounts() + if len(accounts) != 1 || accounts[0].ID != acc.ID { + return errors.New("account should exist") + } else if accounts[0].Balance.Cmp(types.ZeroCurrency.Big()) == 0 { + return errors.New("account isn't funded") + } else if accounts[0].RequiresSync { + return fmt.Errorf("account shouldn't require a sync, got %v", accounts[0].RequiresSync) + } + return nil + }) +} // TestUploadDownloadSameHost uploads a file to the same host through different // contracts and tries downloading the file again. From 34fbc0974a1dabe2dd1229e229251917fe52a636 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 15:16:25 +0200 Subject: [PATCH 26/41] e2e: fix TestFormContract --- internal/test/e2e/contracts_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/test/e2e/contracts_test.go b/internal/test/e2e/contracts_test.go index fcafdd2ac..25f74fa8d 100644 --- a/internal/test/e2e/contracts_test.go +++ b/internal/test/e2e/contracts_test.go @@ -35,10 +35,10 @@ func TestFormContract(t *testing.T) { tt.OK(err) // form a contract using the bus - cs, _ := b.ConsensusState(context.Background()) wallet, _ := b.Wallet(context.Background()) - endHeight := cs.BlockHeight + test.AutopilotConfig.Contracts.Period + test.AutopilotConfig.Contracts.RenewWindow - contract, err := b.FormContract(context.Background(), wallet.Address, types.Siacoins(1), h.PublicKey, h.NetAddress, types.Siacoins(1), endHeight) + ap, err := b.Autopilot(context.Background(), api.DefaultAutopilotID) + tt.OK(err) + contract, err := b.FormContract(context.Background(), wallet.Address, types.Siacoins(1), h.PublicKey, h.NetAddress, types.Siacoins(1), ap.EndHeight()) tt.OK(err) // assert the contract was added to the bus From 49d7f66a8a33e5eddbb2c39bd7de395203b22488 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 15:16:25 +0200 Subject: [PATCH 27/41] e2e: fix TestFormContract --- internal/test/e2e/contracts_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/test/e2e/contracts_test.go b/internal/test/e2e/contracts_test.go index fcafdd2ac..25f74fa8d 100644 --- a/internal/test/e2e/contracts_test.go +++ b/internal/test/e2e/contracts_test.go @@ -35,10 +35,10 @@ func TestFormContract(t *testing.T) { tt.OK(err) // form a contract using the bus - cs, _ := b.ConsensusState(context.Background()) wallet, _ := b.Wallet(context.Background()) - endHeight := cs.BlockHeight + test.AutopilotConfig.Contracts.Period + test.AutopilotConfig.Contracts.RenewWindow - contract, err := b.FormContract(context.Background(), wallet.Address, types.Siacoins(1), h.PublicKey, h.NetAddress, types.Siacoins(1), endHeight) + ap, err := b.Autopilot(context.Background(), api.DefaultAutopilotID) + tt.OK(err) + contract, err := b.FormContract(context.Background(), wallet.Address, types.Siacoins(1), h.PublicKey, h.NetAddress, types.Siacoins(1), ap.EndHeight()) tt.OK(err) // assert the contract was added to the bus From 572aaa16178e5a4076e8a9ab71ba8caa53fd4860 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 16:27:35 +0200 Subject: [PATCH 28/41] stores: add owner column --- stores/accounts.go | 6 +++--- stores/sql/database.go | 6 +++--- stores/sql/main.go | 8 ++++---- stores/sql/mysql/main.go | 16 ++++++++-------- stores/sql/mysql/migrations/main/schema.sql | 4 +++- stores/sql/sqlite/main.go | 16 ++++++++-------- stores/sql/sqlite/migrations/main/schema.sql | 3 ++- 7 files changed, 31 insertions(+), 28 deletions(-) diff --git a/stores/accounts.go b/stores/accounts.go index ce9c64b71..fffc6c27f 100644 --- a/stores/accounts.go +++ b/stores/accounts.go @@ -10,7 +10,7 @@ import ( // Accounts returns all accounts from the db. func (s *SQLStore) Accounts(ctx context.Context, owner string) (accounts []api.Account, err error) { err = s.db.Transaction(ctx, func(tx sql.DatabaseTx) error { - accounts, err = tx.Accounts(ctx) + accounts, err = tx.Accounts(ctx, owner) return err }) return @@ -22,7 +22,7 @@ func (s *SQLStore) Accounts(ctx context.Context, owner string) (accounts []api.A // apply drift. func (s *SQLStore) SetUncleanShutdown(ctx context.Context, owner string) error { return s.db.Transaction(ctx, func(tx sql.DatabaseTx) error { - return tx.SetUncleanShutdown(ctx) + return tx.SetUncleanShutdown(ctx, owner) }) } @@ -30,6 +30,6 @@ func (s *SQLStore) SetUncleanShutdown(ctx context.Context, owner string) error { // ones. func (s *SQLStore) SaveAccounts(ctx context.Context, owner string, accounts []api.Account) error { return s.db.Transaction(ctx, func(tx sql.DatabaseTx) error { - return tx.SaveAccounts(ctx, accounts) + return tx.SaveAccounts(ctx, owner, accounts) }) } diff --git a/stores/sql/database.go b/stores/sql/database.go index cc2aab0df..68c42fee1 100644 --- a/stores/sql/database.go +++ b/stores/sql/database.go @@ -52,7 +52,7 @@ type ( AbortMultipartUpload(ctx context.Context, bucket, key string, uploadID string) error // Accounts returns all accounts from the db. - Accounts(ctx context.Context) ([]api.Account, error) + Accounts(ctx context.Context, owner string) ([]api.Account, error) // AddMultipartPart adds a part to an unfinished multipart upload. AddMultipartPart(ctx context.Context, bucket, key, contractSet, eTag, uploadID string, partNumber int, slices object.SlabSlices) error @@ -308,7 +308,7 @@ type ( // SaveAccounts saves the given accounts in the db, overwriting any // existing ones and setting the clean shutdown flag. - SaveAccounts(ctx context.Context, accounts []api.Account) error + SaveAccounts(ctx context.Context, owner string, accounts []api.Account) error // SearchHosts returns a list of hosts that match the provided filters SearchHosts(ctx context.Context, autopilotID, filterMode, usabilityMode, addressContains string, keyIn []types.PublicKey, offset, limit int) ([]api.Host, error) @@ -319,7 +319,7 @@ type ( // SetUncleanShutdown sets the clean shutdown flag on the accounts to // 'false' and also marks them as requiring a resync. - SetUncleanShutdown(ctx context.Context) error + SetUncleanShutdown(ctx context.Context, owner string) error // SetContractSet creates the contract set with the given name and // associates it with the provided contract IDs. diff --git a/stores/sql/main.go b/stores/sql/main.go index bb03bd86d..38aa1949b 100644 --- a/stores/sql/main.go +++ b/stores/sql/main.go @@ -97,8 +97,8 @@ func AbortMultipartUpload(ctx context.Context, tx sql.Tx, bucket, key string, up return errors.New("failed to delete multipart upload for unknown reason") } -func Accounts(ctx context.Context, tx sql.Tx) ([]api.Account, error) { - rows, err := tx.Query(ctx, "SELECT account_id, clean_shutdown, host, balance, drift, requires_sync FROM ephemeral_accounts") +func Accounts(ctx context.Context, tx sql.Tx, owner string) ([]api.Account, error) { + rows, err := tx.Query(ctx, "SELECT account_id, clean_shutdown, host, balance, drift, requires_sync FROM ephemeral_accounts WHERE owner = ?", owner) if err != nil { return nil, fmt.Errorf("failed to fetch accounts: %w", err) } @@ -2229,8 +2229,8 @@ func Settings(ctx context.Context, tx sql.Tx) ([]string, error) { return settings, nil } -func SetUncleanShutdown(ctx context.Context, tx sql.Tx) error { - _, err := tx.Exec(ctx, "UPDATE ephemeral_accounts SET clean_shutdown = 0, requires_sync = 1") +func SetUncleanShutdown(ctx context.Context, tx sql.Tx, owner string) error { + _, err := tx.Exec(ctx, "UPDATE ephemeral_accounts SET clean_shutdown = 0, requires_sync = 1 WHERE owner = ?", owner) if err != nil { return fmt.Errorf("failed to set unclean shutdown: %w", err) } diff --git a/stores/sql/mysql/main.go b/stores/sql/mysql/main.go index 08ff0010e..acca456fb 100644 --- a/stores/sql/mysql/main.go +++ b/stores/sql/mysql/main.go @@ -95,8 +95,8 @@ func (tx *MainDatabaseTx) AbortMultipartUpload(ctx context.Context, bucket, path return ssql.AbortMultipartUpload(ctx, tx, bucket, path, uploadID) } -func (tx *MainDatabaseTx) Accounts(ctx context.Context) ([]api.Account, error) { - return ssql.Accounts(ctx, tx) +func (tx *MainDatabaseTx) Accounts(ctx context.Context, owner string) ([]api.Account, error) { + return ssql.Accounts(ctx, tx, owner) } func (tx *MainDatabaseTx) AddMultipartPart(ctx context.Context, bucket, path, contractSet, eTag, uploadID string, partNumber int, slices object.SlabSlices) error { @@ -716,11 +716,11 @@ func (tx *MainDatabaseTx) ResetLostSectors(ctx context.Context, hk types.PublicK return ssql.ResetLostSectors(ctx, tx, hk) } -func (tx MainDatabaseTx) SaveAccounts(ctx context.Context, accounts []api.Account) error { +func (tx MainDatabaseTx) SaveAccounts(ctx context.Context, owner string, accounts []api.Account) error { // clean_shutdown = 1 after save stmt, err := tx.Prepare(ctx, ` - INSERT INTO ephemeral_accounts (created_at, account_id, clean_shutdown, host, balance, drift, requires_sync) - VAlUES (?, ?, 1, ?, ?, ?, ?) + INSERT INTO ephemeral_accounts (created_at, account_id, clean_shutdown, host, balance, drift, requires_sync, owner) + VAlUES (?, ?, 1, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE account_id = VALUES(account_id), clean_shutdown = 1, @@ -735,7 +735,7 @@ func (tx MainDatabaseTx) SaveAccounts(ctx context.Context, accounts []api.Accoun defer stmt.Close() for _, acc := range accounts { - res, err := stmt.Exec(ctx, time.Now(), (ssql.PublicKey)(acc.ID), (ssql.PublicKey)(acc.HostKey), (*ssql.BigInt)(acc.Balance), (*ssql.BigInt)(acc.Drift), acc.RequiresSync) + res, err := stmt.Exec(ctx, time.Now(), (ssql.PublicKey)(acc.ID), (ssql.PublicKey)(acc.HostKey), (*ssql.BigInt)(acc.Balance), (*ssql.BigInt)(acc.Drift), acc.RequiresSync, owner) if err != nil { return fmt.Errorf("failed to insert account %v: %w", acc.ID, err) } else if n, err := res.RowsAffected(); err != nil { @@ -827,8 +827,8 @@ func (tx *MainDatabaseTx) Settings(ctx context.Context) ([]string, error) { return ssql.Settings(ctx, tx) } -func (tx *MainDatabaseTx) SetUncleanShutdown(ctx context.Context) error { - return ssql.SetUncleanShutdown(ctx, tx) +func (tx *MainDatabaseTx) SetUncleanShutdown(ctx context.Context, owner string) error { + return ssql.SetUncleanShutdown(ctx, tx, owner) } func (tx *MainDatabaseTx) Slab(ctx context.Context, key object.EncryptionKey) (object.Slab, error) { diff --git a/stores/sql/mysql/migrations/main/schema.sql b/stores/sql/mysql/migrations/main/schema.sql index 51a5c5629..96008942a 100644 --- a/stores/sql/mysql/migrations/main/schema.sql +++ b/stores/sql/mysql/migrations/main/schema.sql @@ -225,9 +225,11 @@ CREATE TABLE `ephemeral_accounts` ( `balance` longtext, `drift` longtext, `requires_sync` tinyint(1) DEFAULT NULL, + `owner` varchar(128) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `account_id` (`account_id`), - KEY `idx_ephemeral_accounts_requires_sync` (`requires_sync`) + KEY `idx_ephemeral_accounts_requires_sync` (`requires_sync`), + KEY `idx_ephemeral_accounts_owner` (`owner`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- dbAllowlistEntry diff --git a/stores/sql/sqlite/main.go b/stores/sql/sqlite/main.go index b72ec5e8c..f26284966 100644 --- a/stores/sql/sqlite/main.go +++ b/stores/sql/sqlite/main.go @@ -90,8 +90,8 @@ func (b *MainDatabase) wrapTxn(tx sql.Tx) *MainDatabaseTx { return &MainDatabaseTx{tx, b.log.Named(hex.EncodeToString(frand.Bytes(16)))} } -func (tx *MainDatabaseTx) Accounts(ctx context.Context) ([]api.Account, error) { - return ssql.Accounts(ctx, tx) +func (tx *MainDatabaseTx) Accounts(ctx context.Context, owner string) ([]api.Account, error) { + return ssql.Accounts(ctx, tx, owner) } func (tx *MainDatabaseTx) AbortMultipartUpload(ctx context.Context, bucket, path string, uploadID string) error { @@ -714,11 +714,11 @@ func (tx *MainDatabaseTx) ResetLostSectors(ctx context.Context, hk types.PublicK return ssql.ResetLostSectors(ctx, tx, hk) } -func (tx *MainDatabaseTx) SaveAccounts(ctx context.Context, accounts []api.Account) error { +func (tx *MainDatabaseTx) SaveAccounts(ctx context.Context, owner string, accounts []api.Account) error { // clean_shutdown = 1 after save stmt, err := tx.Prepare(ctx, ` - INSERT INTO ephemeral_accounts (created_at, account_id, clean_shutdown, host, balance, drift, requires_sync) - VAlUES (?, ?, 1, ?, ?, ?, ?) + INSERT INTO ephemeral_accounts (created_at, account_id, clean_shutdown, host, balance, drift, requires_sync, owner) + VAlUES (?, ?, 1, ?, ?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET account_id = EXCLUDED.account_id, clean_shutdown = 1, @@ -733,7 +733,7 @@ func (tx *MainDatabaseTx) SaveAccounts(ctx context.Context, accounts []api.Accou defer stmt.Close() for _, acc := range accounts { - res, err := stmt.Exec(ctx, time.Now(), (ssql.PublicKey)(acc.ID), (ssql.PublicKey)(acc.HostKey), (*ssql.BigInt)(acc.Balance), (*ssql.BigInt)(acc.Drift), acc.RequiresSync) + res, err := stmt.Exec(ctx, time.Now(), (ssql.PublicKey)(acc.ID), (ssql.PublicKey)(acc.HostKey), (*ssql.BigInt)(acc.Balance), (*ssql.BigInt)(acc.Drift), acc.RequiresSync, owner) if err != nil { return fmt.Errorf("failed to insert account %v: %w", acc.ID, err) } else if n, err := res.RowsAffected(); err != nil { @@ -826,8 +826,8 @@ func (tx *MainDatabaseTx) Settings(ctx context.Context) ([]string, error) { return ssql.Settings(ctx, tx) } -func (tx *MainDatabaseTx) SetUncleanShutdown(ctx context.Context) error { - return ssql.SetUncleanShutdown(ctx, tx) +func (tx *MainDatabaseTx) SetUncleanShutdown(ctx context.Context, owner string) error { + return ssql.SetUncleanShutdown(ctx, tx, owner) } func (tx *MainDatabaseTx) Slab(ctx context.Context, key object.EncryptionKey) (object.Slab, error) { diff --git a/stores/sql/sqlite/migrations/main/schema.sql b/stores/sql/sqlite/migrations/main/schema.sql index 647e6cfdd..6d8d0ee6c 100644 --- a/stores/sql/sqlite/migrations/main/schema.sql +++ b/stores/sql/sqlite/migrations/main/schema.sql @@ -130,8 +130,9 @@ CREATE TABLE `settings` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` dat CREATE INDEX `idx_settings_key` ON `settings`(`key`); -- dbAccount -CREATE TABLE `ephemeral_accounts` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`account_id` blob NOT NULL UNIQUE,`clean_shutdown` numeric DEFAULT false,`host` blob NOT NULL,`balance` text,`drift` text,`requires_sync` numeric); +CREATE TABLE `ephemeral_accounts` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`account_id` blob NOT NULL UNIQUE,`clean_shutdown` numeric DEFAULT false,`host` blob NOT NULL,`balance` text,`drift` text,`requires_sync` numeric, `owner` text NOT NULL); CREATE INDEX `idx_ephemeral_accounts_requires_sync` ON `ephemeral_accounts`(`requires_sync`); +CREATE INDEX `idx_ephemeral_accounts_owner` ON `ephemeral_accounts`(`owner`); -- dbAutopilot CREATE TABLE `autopilots` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`identifier` text NOT NULL UNIQUE,`config` text,`current_period` integer DEFAULT 0); From 4a3996ec1b9c673a10af2eb4fad95ca6ca9b15cb Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 16:45:19 +0200 Subject: [PATCH 29/41] sql: migration code --- internal/sql/migrations.go | 6 ++++++ .../main/migration_00016_account_owner.sql | 17 +++++++++++++++++ .../main/migration_00016_account_owner.sql | 5 +++++ 3 files changed, 28 insertions(+) create mode 100644 stores/sql/mysql/migrations/main/migration_00016_account_owner.sql create mode 100644 stores/sql/sqlite/migrations/main/migration_00016_account_owner.sql diff --git a/internal/sql/migrations.go b/internal/sql/migrations.go index 377bf6fc5..9b98be300 100644 --- a/internal/sql/migrations.go +++ b/internal/sql/migrations.go @@ -205,6 +205,12 @@ var ( return performMigration(ctx, tx, migrationsFs, dbIdentifier, "00015_reset_drift", log) }, }, + { + ID: "00016_account_owner", + Migrate: func(tx Tx) error { + return performMigration(ctx, tx, migrationsFs, dbIdentifier, "00016_account_owner", log) + }, + }, } } MetricsMigrations = func(ctx context.Context, migrationsFs embed.FS, log *zap.SugaredLogger) []Migration { diff --git a/stores/sql/mysql/migrations/main/migration_00016_account_owner.sql b/stores/sql/mysql/migrations/main/migration_00016_account_owner.sql new file mode 100644 index 000000000..8f188ae7a --- /dev/null +++ b/stores/sql/mysql/migrations/main/migration_00016_account_owner.sql @@ -0,0 +1,17 @@ +DROP TABLE IF EXISTS ephemeral_accounts; + +CREATE TABLE `ephemeral_accounts` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `created_at` datetime(3) DEFAULT NULL, + `account_id` varbinary(32) NOT NULL, + `clean_shutdown` tinyint(1) DEFAULT '0', + `host` longblob NOT NULL, + `balance` longtext, + `drift` longtext, + `requires_sync` tinyint(1) DEFAULT NULL, + `owner` varchar(128) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `account_id` (`account_id`), + KEY `idx_ephemeral_accounts_requires_sync` (`requires_sync`), + KEY `idx_ephemeral_accounts_owner` (`owner`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; \ No newline at end of file diff --git a/stores/sql/sqlite/migrations/main/migration_00016_account_owner.sql b/stores/sql/sqlite/migrations/main/migration_00016_account_owner.sql new file mode 100644 index 000000000..359830ba0 --- /dev/null +++ b/stores/sql/sqlite/migrations/main/migration_00016_account_owner.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS ephemeral_accounts; + +CREATE TABLE `ephemeral_accounts` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`account_id` blob NOT NULL UNIQUE,`clean_shutdown` numeric DEFAULT false,`host` blob NOT NULL,`balance` text,`drift` text,`requires_sync` numeric, `owner` text NOT NULL); +CREATE INDEX `idx_ephemeral_accounts_requires_sync` ON `ephemeral_accounts`(`requires_sync`); +CREATE INDEX `idx_ephemeral_accounts_owner` ON `ephemeral_accounts`(`owner`); \ No newline at end of file From c638e38e4053cf0b186458044fd81446e0bb7397 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 16:56:50 +0200 Subject: [PATCH 30/41] e2e: adjust TestEphemeralAccountSync --- internal/test/e2e/cluster_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/test/e2e/cluster_test.go b/internal/test/e2e/cluster_test.go index 864297eb2..de45c28e5 100644 --- a/internal/test/e2e/cluster_test.go +++ b/internal/test/e2e/cluster_test.go @@ -1310,8 +1310,12 @@ func TestEphemeralAccountSync(t *testing.T) { // Fetch the account balance before setting the balance accounts := cluster.Accounts() - if len(accounts) != 1 || accounts[0].RequiresSync { - t.Fatal("account shouldn't require a sync") + if len(accounts) != 1 { + t.Fatal("account should exist") + } else if accounts[0].Balance.Cmp(types.ZeroCurrency.Big()) == 0 { + t.Fatal("account isn't funded") + } else if accounts[0].RequiresSync { + t.Fatalf("account shouldn't require a sync, got %v", accounts[0].RequiresSync) } acc := accounts[0] From b63741cd2b1bbc0cb6bce64b18bf50b7bb18852d Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Thu, 22 Aug 2024 17:35:08 +0200 Subject: [PATCH 31/41] e2e: don't run TestEphemeralAccountSync on MySQL --- internal/test/e2e/cluster_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/test/e2e/cluster_test.go b/internal/test/e2e/cluster_test.go index de45c28e5..bae178842 100644 --- a/internal/test/e2e/cluster_test.go +++ b/internal/test/e2e/cluster_test.go @@ -26,6 +26,7 @@ import ( "go.sia.tech/renterd/alerts" "go.sia.tech/renterd/api" "go.sia.tech/renterd/autopilot/contractor" + "go.sia.tech/renterd/config" "go.sia.tech/renterd/internal/test" "go.sia.tech/renterd/internal/utils" "go.sia.tech/renterd/object" @@ -1298,6 +1299,8 @@ func TestParallelDownload(t *testing.T) { func TestEphemeralAccountSync(t *testing.T) { if testing.Short() { t.SkipNow() + } else if mysqlCfg := config.MySQLConfigFromEnv(); mysqlCfg.URI != "" { + t.Skip("skipping MySQL suite") } dir := t.TempDir() From 451e207227c192a1c7f941a54449e9342e6b864a Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Fri, 23 Aug 2024 11:25:58 +0200 Subject: [PATCH 32/41] e2e: wait for contracts in test --- internal/test/e2e/cluster_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/test/e2e/cluster_test.go b/internal/test/e2e/cluster_test.go index bae178842..67889980c 100644 --- a/internal/test/e2e/cluster_test.go +++ b/internal/test/e2e/cluster_test.go @@ -1347,6 +1347,9 @@ func TestEphemeralAccountSync(t *testing.T) { // ask for the account, this should trigger its creation tt.OKAll(cluster.Worker.Account(context.Background(), hk)) + // make sure we form a contract + cluster.WaitForContracts() + accounts = cluster.Accounts() if len(accounts) != 1 || accounts[0].ID != acc.ID { t.Fatal("account should exist") From 161e5a5d893d854773b5e28c0c164537d9440708 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Fri, 23 Aug 2024 11:44:05 +0200 Subject: [PATCH 33/41] e2e: mine block --- internal/test/e2e/cluster_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/test/e2e/cluster_test.go b/internal/test/e2e/cluster_test.go index 67889980c..f621644fe 100644 --- a/internal/test/e2e/cluster_test.go +++ b/internal/test/e2e/cluster_test.go @@ -1333,7 +1333,6 @@ func TestEphemeralAccountSync(t *testing.T) { // start the cluster again cluster = newTestCluster(t, testClusterOptions{ dir: cluster.dir, - dbName: cluster.dbName, logger: cluster.logger, walletKey: &cluster.wk, }) @@ -1349,6 +1348,7 @@ func TestEphemeralAccountSync(t *testing.T) { // make sure we form a contract cluster.WaitForContracts() + cluster.MineBlocks(1) accounts = cluster.Accounts() if len(accounts) != 1 || accounts[0].ID != acc.ID { From 0ca6c9cc7e1c9b262415a8fec3b3f400eb1150d7 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Fri, 23 Aug 2024 13:31:01 +0200 Subject: [PATCH 34/41] e2e: extend TestEphemeralAccounts --- internal/test/e2e/cluster_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/test/e2e/cluster_test.go b/internal/test/e2e/cluster_test.go index f621644fe..60b1a5a5c 100644 --- a/internal/test/e2e/cluster_test.go +++ b/internal/test/e2e/cluster_test.go @@ -1171,6 +1171,31 @@ func TestEphemeralAccounts(t *testing.T) { } return nil }) + + // manuall save accounts in bus + tt.OK(cluster.Bus.UpdateAccounts(context.Background(), "owner", []api.Account{acc}, false)) + + // fetch again + busAccounts, err := cluster.Bus.Accounts(context.Background(), "owner") + tt.OK(err) + if len(busAccounts) != 1 || busAccounts[0].ID != acc.ID || busAccounts[0].CleanShutdown != acc.CleanShutdown { + t.Fatalf("expected 1 clean account, got %v", len(busAccounts)) + } + + // again but with invalid owner + busAccounts, err = cluster.Bus.Accounts(context.Background(), "invalid") + tt.OK(err) + if len(busAccounts) != 0 { + t.Fatalf("expected 0 accounts, got %v", len(busAccounts)) + } + + // mark accounts unclean + tt.OK(cluster.Bus.UpdateAccounts(context.Background(), "owner", nil, true)) + busAccounts, err = cluster.Bus.Accounts(context.Background(), "owner") + tt.OK(err) + if len(busAccounts) != 1 || busAccounts[0].ID != acc.ID || busAccounts[0].CleanShutdown { + t.Fatalf("expected 1 unclean account, got %v", len(busAccounts)) + } } // TestParallelUpload tests uploading multiple files in parallel. @@ -1357,6 +1382,7 @@ func TestEphemeralAccountSync(t *testing.T) { t.Fatalf("account shouldn't be marked as clean shutdown or not require a sync, got %v", accounts[0]) } + // assert account was funded tt.Retry(100, 100*time.Millisecond, func() error { accounts = cluster.Accounts() if len(accounts) != 1 || accounts[0].ID != acc.ID { From 1111f8c6557c2f65f0fb28e153ababd4ecd7f6df Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Mon, 26 Aug 2024 14:41:20 +0200 Subject: [PATCH 35/41] accounts: address comments --- api/account.go | 4 ++++ bus/routes.go | 3 --- cmd/renterd/config.go | 16 +++++++++++++++- internal/test/e2e/cluster_test.go | 2 ++ internal/worker/accounts.go | 9 ++++++--- internal/worker/accounts_test.go | 5 +++++ stores/sql/main.go | 11 +++++++++-- worker/worker.go | 4 ++++ 8 files changed, 45 insertions(+), 9 deletions(-) diff --git a/api/account.go b/api/account.go index 7cecd3bae..46ed69c00 100644 --- a/api/account.go +++ b/api/account.go @@ -33,6 +33,10 @@ type ( // an account and the balance reported by a host. Drift *big.Int `json:"drift"` + // Owner is the owner of the account which is responsible for funding + // it. + Owner string `json:"owner"` + // RequiresSync indicates whether an account needs to be synced with the // host before it can be used again. RequiresSync bool `json:"requiresSync"` diff --git a/bus/routes.go b/bus/routes.go index 279120909..b5c8d6989 100644 --- a/bus/routes.go +++ b/bus/routes.go @@ -1714,9 +1714,6 @@ func (b *Bus) accountsHandlerGET(jc jape.Context) { var owner string if jc.DecodeForm("owner", &owner) != nil { return - } else if owner == "" { - jc.Error(errors.New("owner is required"), http.StatusBadRequest) - return } accounts, err := b.accounts.Accounts(jc.Request.Context(), owner) if err != nil { diff --git a/cmd/renterd/config.go b/cmd/renterd/config.go index da85dabc8..e1200f121 100644 --- a/cmd/renterd/config.go +++ b/cmd/renterd/config.go @@ -97,7 +97,7 @@ func defaultConfig() config.Config { Worker: config.Worker{ Enabled: true, - ID: "worker", + ID: "", AccountsRefillInterval: defaultAccountRefillInterval, ContractLockTimeout: 30 * time.Second, BusFlushInterval: 5 * time.Second, @@ -132,6 +132,15 @@ func defaultConfig() config.Config { } } +func assertWorkerID(cfg *config.Config) error { + if cfg.Bus.RemoteAddr != "" && cfg.Worker.ID == "" { + return errors.New("a unique worker ID must be set in a cluster setup") + } else if cfg.Worker.ID == "" { + cfg.Worker.ID = "worker" + } + return nil +} + // loadConfig creates a default config and overrides it with the contents of the // YAML file (specified by the RENTERD_CONFIG_FILE), CLI flags, and environment // variables, in that order. @@ -141,6 +150,11 @@ func loadConfig() (cfg config.Config, network *consensus.Network, genesis types. parseCLIFlags(&cfg) parseEnvironmentVariables(&cfg) + // check worker id + if err = assertWorkerID(&cfg); err != nil { + return + } + // check network switch cfg.Network { case "anagami": diff --git a/internal/test/e2e/cluster_test.go b/internal/test/e2e/cluster_test.go index 60b1a5a5c..b3c18206d 100644 --- a/internal/test/e2e/cluster_test.go +++ b/internal/test/e2e/cluster_test.go @@ -1151,6 +1151,8 @@ func TestEphemeralAccounts(t *testing.T) { t.Fatal("wrong host") } else if !acc.CleanShutdown { t.Fatal("account should indicate a clean shutdown") + } else if acc.Owner != testWorkerCfg().ID { + t.Fatalf("wrong owner %v", acc.Owner) } // Check that the spending was recorded for the contract. The recorded diff --git a/internal/worker/accounts.go b/internal/worker/accounts.go index 05bed7c67..527aa8ba6 100644 --- a/internal/worker/accounts.go +++ b/internal/worker/accounts.go @@ -52,6 +52,7 @@ type ( type ( AccountMgr struct { + alerts alerts.Alerter w AccountMgrWorker dc DownloadContracts cs ConsensusState @@ -180,6 +181,7 @@ func (a *AccountMgr) account(hk types.PublicKey) *Account { HostKey: hk, Balance: big.NewInt(0), Drift: big.NewInt(0), + Owner: a.owner, RequiresSync: true, // force sync on new account }, } @@ -352,16 +354,16 @@ func (a *AccountMgr) refillAccount(ctx context.Context, contract api.ContractMet // negative because we don't care if we have more money than // expected. if account.Drift.Cmp(maxNegDrift) < 0 { - // TODO: register alert - _ = newAccountRefillAlert(account.ID, contract, errMaxDriftExceeded, + alert := newAccountRefillAlert(account.ID, contract, errMaxDriftExceeded, "accountID", account.ID.String(), "hostKey", contract.HostKey.String(), "balance", account.Balance.String(), "drift", account.Drift.String(), ) + _ = a.alerts.RegisterAlert(a.shutdownCtx, alert) return fmt.Errorf("not refilling account since host is potentially cheating: %w", errMaxDriftExceeded) } else { - // TODO: dismiss alert on success + _ = a.alerts.DismissAlerts(a.shutdownCtx, alerts.IDForAccount(alertAccountRefillID, account.ID)) } // check if a resync is needed @@ -498,6 +500,7 @@ func (a *Account) convert() api.Account { HostKey: a.acc.HostKey, Balance: new(big.Int).Set(a.acc.Balance), Drift: new(big.Int).Set(a.acc.Drift), + Owner: a.acc.Owner, RequiresSync: a.acc.RequiresSync, } } diff --git a/internal/worker/accounts_test.go b/internal/worker/accounts_test.go index 2c75d070d..ba4a61427 100644 --- a/internal/worker/accounts_test.go +++ b/internal/worker/accounts_test.go @@ -74,6 +74,7 @@ func TestAccounts(t *testing.T) { HostKey: hk, Balance: types.ZeroCurrency.Big(), Drift: types.ZeroCurrency.Big(), + Owner: "test", }); !cmp.Equal(acc, expected, comparer) { t.Fatal("account doesn't match expectation", cmp.Diff(acc, expected, comparer)) } @@ -90,6 +91,7 @@ func TestAccounts(t *testing.T) { HostKey: hk, Balance: types.ZeroCurrency.Big(), Drift: types.ZeroCurrency.Big(), + Owner: "test", }); !cmp.Equal(acc, expected, comparer) { t.Fatal("account doesn't match expectation", cmp.Diff(acc, expected, comparer)) } @@ -105,6 +107,7 @@ func TestAccounts(t *testing.T) { HostKey: hk, Balance: types.Siacoins(1).Big(), Drift: types.ZeroCurrency.Big(), + Owner: "test", }); !cmp.Equal(acc, expected, comparer) { t.Fatal("account doesn't match expectation", cmp.Diff(acc, expected, comparer)) } @@ -120,6 +123,7 @@ func TestAccounts(t *testing.T) { HostKey: hk, Balance: types.Siacoins(1).Big(), Drift: types.ZeroCurrency.Big(), + Owner: "test", }); !cmp.Equal(acc, expected, comparer) { t.Fatal("account doesn't match expectation", cmp.Diff(acc, expected, comparer)) } @@ -136,6 +140,7 @@ func TestAccounts(t *testing.T) { HostKey: hk, Balance: newBalance, Drift: newDrift, + Owner: "test", }); !cmp.Equal(acc, expected, comparer) { t.Fatal("account doesn't match expectation", cmp.Diff(acc, expected, comparer)) } diff --git a/stores/sql/main.go b/stores/sql/main.go index 38aa1949b..90f8391d1 100644 --- a/stores/sql/main.go +++ b/stores/sql/main.go @@ -98,7 +98,14 @@ func AbortMultipartUpload(ctx context.Context, tx sql.Tx, bucket, key string, up } func Accounts(ctx context.Context, tx sql.Tx, owner string) ([]api.Account, error) { - rows, err := tx.Query(ctx, "SELECT account_id, clean_shutdown, host, balance, drift, requires_sync FROM ephemeral_accounts WHERE owner = ?", owner) + var whereExpr string + var args []any + if owner != "" { + whereExpr = "WHERE owner = ?" + args = append(args, owner) + } + rows, err := tx.Query(ctx, fmt.Sprintf("SELECT account_id, clean_shutdown, host, balance, drift, requires_sync, owner FROM ephemeral_accounts %s", whereExpr), + args...) if err != nil { return nil, fmt.Errorf("failed to fetch accounts: %w", err) } @@ -107,7 +114,7 @@ func Accounts(ctx context.Context, tx sql.Tx, owner string) ([]api.Account, erro var accounts []api.Account for rows.Next() { a := api.Account{Balance: new(big.Int), Drift: new(big.Int)} // init big.Int - if err := rows.Scan((*PublicKey)(&a.ID), &a.CleanShutdown, (*PublicKey)(&a.HostKey), (*BigInt)(a.Balance), (*BigInt)(a.Drift), &a.RequiresSync); err != nil { + if err := rows.Scan((*PublicKey)(&a.ID), &a.CleanShutdown, (*PublicKey)(&a.HostKey), (*BigInt)(a.Balance), (*BigInt)(a.Drift), &a.RequiresSync, &a.Owner); err != nil { return nil, fmt.Errorf("failed to scan account: %w", err) } accounts = append(accounts, a) diff --git a/worker/worker.go b/worker/worker.go index ae763d3ac..1d0311481 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -1137,6 +1137,10 @@ func (w *Worker) stateHandlerGET(jc jape.Context) { // New returns an HTTP handler that serves the worker API. func New(cfg config.Worker, masterKey [32]byte, b Bus, l *zap.Logger) (*Worker, error) { + if cfg.ID == "" { + return nil, errors.New("worker ID cannot be empty") + } + l = l.Named("worker").Named(cfg.ID) if cfg.ContractLockTimeout == 0 { From 7022b7e60c736c8ff39479ef2144ddd6b9fd29e8 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Mon, 26 Aug 2024 16:18:51 +0200 Subject: [PATCH 36/41] worker: fix TestUpload --- internal/worker/accounts.go | 3 ++- internal/worker/accounts_test.go | 15 ++++++++++++++- worker/worker.go | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/worker/accounts.go b/internal/worker/accounts.go index 527aa8ba6..717a0fa49 100644 --- a/internal/worker/accounts.go +++ b/internal/worker/accounts.go @@ -87,11 +87,12 @@ type ( // NewAccountManager creates a new account manager. It will load all accounts // from the given store and mark the shutdown as unclean. When Shutdown is // called it will save all accounts. -func NewAccountManager(key types.PrivateKey, owner string, w AccountMgrWorker, cs ConsensusState, dc DownloadContracts, s AccountStore, refillInterval time.Duration, l *zap.Logger) (*AccountMgr, error) { +func NewAccountManager(key types.PrivateKey, owner string, alerter alerts.Alerter, w AccountMgrWorker, cs ConsensusState, dc DownloadContracts, s AccountStore, refillInterval time.Duration, l *zap.Logger) (*AccountMgr, error) { logger := l.Named("accounts").Sugar() shutdownCtx, shutdownCancel := context.WithCancel(context.Background()) a := &AccountMgr{ + alerts: alerter, w: w, cs: cs, dc: dc, diff --git a/internal/worker/accounts_test.go b/internal/worker/accounts_test.go index ba4a61427..039f44358 100644 --- a/internal/worker/accounts_test.go +++ b/internal/worker/accounts_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/go-cmp/cmp" "go.sia.tech/core/types" + "go.sia.tech/renterd/alerts" "go.sia.tech/renterd/api" "go.uber.org/zap" ) @@ -16,6 +17,18 @@ type mockAccountMgrBackend struct { contracts []api.ContractMetadata } +func (b *mockAccountMgrBackend) Alerts(context.Context, alerts.AlertsOpts) (alerts.AlertsResponse, error) { + return alerts.AlertsResponse{}, nil +} + +func (b *mockAccountMgrBackend) DismissAlerts(context.Context, ...types.Hash256) error { + return nil +} + +func (b *mockAccountMgrBackend) RegisterAlert(context.Context, alerts.Alert) error { + return nil +} + func (b *mockAccountMgrBackend) FundAccount(ctx context.Context, fcid types.FileContractID, hk types.PublicKey, siamuxAddr string, balance types.Currency) error { return nil } @@ -46,7 +59,7 @@ func TestAccounts(t *testing.T) { }, }, } - mgr, err := NewAccountManager(types.GeneratePrivateKey(), "test", b, b, b, b, time.Second, zap.NewNop()) + mgr, err := NewAccountManager(types.GeneratePrivateKey(), "test", b, b, b, b, b, time.Second, zap.NewNop()) if err != nil { t.Fatal(err) } diff --git a/worker/worker.go b/worker/worker.go index 1d0311481..a25fc981e 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -1626,7 +1626,7 @@ func (w *Worker) initAccounts(refillInterval time.Duration) (err error) { panic("priceTables already initialized") // developer error } keyPath := fmt.Sprintf("accounts/%s", w.id) - w.accounts, err = iworker.NewAccountManager(w.deriveSubKey(keyPath), w.id, w, w.bus, w.cache, w.bus, refillInterval, w.logger.Desugar()) + w.accounts, err = iworker.NewAccountManager(w.deriveSubKey(keyPath), w.id, w.bus, w, w.bus, w.cache, w.bus, refillInterval, w.logger.Desugar()) return err } From c90c19a1471f3b04d4dc326614ac4ac4810e038d Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Tue, 27 Aug 2024 10:19:20 +0200 Subject: [PATCH 37/41] worker: address comments --- api/bus.go | 4 +--- bus/bus.go | 3 +-- bus/client/accounts.go | 6 ++---- bus/routes.go | 16 ++++++++-------- internal/test/e2e/cluster_test.go | 11 +++++++---- internal/worker/accounts.go | 10 +++++++--- internal/worker/accounts_test.go | 2 +- stores/accounts.go | 4 ++-- stores/sql/database.go | 4 ++-- stores/sql/mysql/main.go | 8 ++++---- stores/sql/sqlite/main.go | 8 ++++---- worker/mocks_test.go | 2 +- 12 files changed, 40 insertions(+), 38 deletions(-) diff --git a/api/bus.go b/api/bus.go index 86c3f5da4..a0f33dcf0 100644 --- a/api/bus.go +++ b/api/bus.go @@ -46,9 +46,7 @@ type ( type ( AccountsSaveRequest struct { - Owner string `json:"owner"` - Accounts []Account `json:"accounts"` - SetUnclean bool `json:"setUnclean"` + Accounts []Account `json:"accounts"` } // BusStateResponse is the response type for the /bus/state endpoint. diff --git a/bus/bus.go b/bus/bus.go index 9bc310bfc..dfc99ce2a 100644 --- a/bus/bus.go +++ b/bus/bus.go @@ -165,8 +165,7 @@ type ( // startup and persisted upon shutdown. AccountStore interface { Accounts(context.Context, string) ([]api.Account, error) - SaveAccounts(context.Context, string, []api.Account) error - SetUncleanShutdown(context.Context, string) error + SaveAccounts(context.Context, []api.Account) error } // An AutopilotStore stores autopilots. diff --git a/bus/client/accounts.go b/bus/client/accounts.go index f9a79290e..11ce58ca2 100644 --- a/bus/client/accounts.go +++ b/bus/client/accounts.go @@ -16,11 +16,9 @@ func (c *Client) Accounts(ctx context.Context, owner string) (accounts []api.Acc } // UpdateAccounts saves all accounts. -func (c *Client) UpdateAccounts(ctx context.Context, owner string, accounts []api.Account, setUnclean bool) (err error) { +func (c *Client) UpdateAccounts(ctx context.Context, accounts []api.Account) (err error) { err = c.c.WithContext(ctx).POST("/accounts", api.AccountsSaveRequest{ - Accounts: accounts, - Owner: owner, - SetUnclean: setUnclean, + Accounts: accounts, }, nil) return } diff --git a/bus/routes.go b/bus/routes.go index b5c8d6989..5fee1aaf1 100644 --- a/bus/routes.go +++ b/bus/routes.go @@ -1727,14 +1727,14 @@ func (b *Bus) accountsHandlerPOST(jc jape.Context) { var req api.AccountsSaveRequest if jc.Decode(&req) != nil { return - } else if req.Owner == "" { - jc.Error(errors.New("owner is required"), http.StatusBadRequest) - return - } else if b.accounts.SaveAccounts(jc.Request.Context(), req.Owner, req.Accounts) != nil { - return - } else if !req.SetUnclean { - return - } else if jc.Check("failed to set accounts unclean", b.accounts.SetUncleanShutdown(jc.Request.Context(), req.Owner)) != nil { + } + for _, acc := range req.Accounts { + if acc.Owner == "" { + jc.Error(errors.New("acocunts need to have a valid 'Owner'"), http.StatusBadRequest) + return + } + } + if b.accounts.SaveAccounts(jc.Request.Context(), req.Accounts) != nil { return } } diff --git a/internal/test/e2e/cluster_test.go b/internal/test/e2e/cluster_test.go index b3c18206d..6d47d0590 100644 --- a/internal/test/e2e/cluster_test.go +++ b/internal/test/e2e/cluster_test.go @@ -1174,8 +1174,9 @@ func TestEphemeralAccounts(t *testing.T) { return nil }) - // manuall save accounts in bus - tt.OK(cluster.Bus.UpdateAccounts(context.Background(), "owner", []api.Account{acc}, false)) + // manuall save accounts in bus for 'owner' and mark it clean + acc.Owner = "owner" + tt.OK(cluster.Bus.UpdateAccounts(context.Background(), []api.Account{acc})) // fetch again busAccounts, err := cluster.Bus.Accounts(context.Background(), "owner") @@ -1192,11 +1193,13 @@ func TestEphemeralAccounts(t *testing.T) { } // mark accounts unclean - tt.OK(cluster.Bus.UpdateAccounts(context.Background(), "owner", nil, true)) + uncleanAcc := acc + uncleanAcc.CleanShutdown = false + tt.OK(cluster.Bus.UpdateAccounts(context.Background(), []api.Account{uncleanAcc})) busAccounts, err = cluster.Bus.Accounts(context.Background(), "owner") tt.OK(err) if len(busAccounts) != 1 || busAccounts[0].ID != acc.ID || busAccounts[0].CleanShutdown { - t.Fatalf("expected 1 unclean account, got %v", len(busAccounts)) + t.Fatalf("expected 1 unclean account, got %v, %v", len(busAccounts), busAccounts[0].CleanShutdown) } } diff --git a/internal/worker/accounts.go b/internal/worker/accounts.go index 717a0fa49..796863d7d 100644 --- a/internal/worker/accounts.go +++ b/internal/worker/accounts.go @@ -38,7 +38,7 @@ type ( AccountStore interface { Accounts(context.Context, string) ([]api.Account, error) - UpdateAccounts(context.Context, string, []api.Account, bool) error + UpdateAccounts(context.Context, []api.Account) error } ConsensusState interface { @@ -150,7 +150,7 @@ func (a *AccountMgr) ResetDrift(id rhpv3.Account) error { func (a *AccountMgr) Shutdown(ctx context.Context) error { accounts := a.Accounts() - err := a.s.UpdateAccounts(ctx, a.owner, accounts, false) + err := a.s.UpdateAccounts(ctx, accounts) if err != nil { a.logger.Errorf("failed to save %v accounts: %v", len(accounts), err) return err @@ -246,7 +246,11 @@ func (a *AccountMgr) run() { a.mu.Unlock() // mark the shutdown as unclean, this will be overwritten on shutdown - err = a.s.UpdateAccounts(a.shutdownCtx, a.owner, nil, true) + uncleanAccounts := append([]api.Account(nil), saved...) + for i := range uncleanAccounts { + uncleanAccounts[i].CleanShutdown = false + } + err = a.s.UpdateAccounts(a.shutdownCtx, uncleanAccounts) if err != nil { a.logger.Error("failed to mark account shutdown as unclean", zap.Error(err)) } diff --git a/internal/worker/accounts_test.go b/internal/worker/accounts_test.go index 039f44358..207724ef1 100644 --- a/internal/worker/accounts_test.go +++ b/internal/worker/accounts_test.go @@ -38,7 +38,7 @@ func (b *mockAccountMgrBackend) SyncAccount(ctx context.Context, fcid types.File func (b *mockAccountMgrBackend) Accounts(context.Context, string) ([]api.Account, error) { return []api.Account{}, nil } -func (b *mockAccountMgrBackend) UpdateAccounts(context.Context, string, []api.Account, bool) error { +func (b *mockAccountMgrBackend) UpdateAccounts(context.Context, []api.Account) error { return nil } func (b *mockAccountMgrBackend) ConsensusState(ctx context.Context) (api.ConsensusState, error) { diff --git a/stores/accounts.go b/stores/accounts.go index fffc6c27f..3fdbfaba7 100644 --- a/stores/accounts.go +++ b/stores/accounts.go @@ -28,8 +28,8 @@ func (s *SQLStore) SetUncleanShutdown(ctx context.Context, owner string) error { // SaveAccounts saves the given accounts in the db, overwriting any existing // ones. -func (s *SQLStore) SaveAccounts(ctx context.Context, owner string, accounts []api.Account) error { +func (s *SQLStore) SaveAccounts(ctx context.Context, accounts []api.Account) error { return s.db.Transaction(ctx, func(tx sql.DatabaseTx) error { - return tx.SaveAccounts(ctx, owner, accounts) + return tx.SaveAccounts(ctx, accounts) }) } diff --git a/stores/sql/database.go b/stores/sql/database.go index 68c42fee1..342acd024 100644 --- a/stores/sql/database.go +++ b/stores/sql/database.go @@ -307,8 +307,8 @@ type ( ResetLostSectors(ctx context.Context, hk types.PublicKey) error // SaveAccounts saves the given accounts in the db, overwriting any - // existing ones and setting the clean shutdown flag. - SaveAccounts(ctx context.Context, owner string, accounts []api.Account) error + // existing ones. + SaveAccounts(ctx context.Context, accounts []api.Account) error // SearchHosts returns a list of hosts that match the provided filters SearchHosts(ctx context.Context, autopilotID, filterMode, usabilityMode, addressContains string, keyIn []types.PublicKey, offset, limit int) ([]api.Host, error) diff --git a/stores/sql/mysql/main.go b/stores/sql/mysql/main.go index acca456fb..91b4d536d 100644 --- a/stores/sql/mysql/main.go +++ b/stores/sql/mysql/main.go @@ -716,14 +716,14 @@ func (tx *MainDatabaseTx) ResetLostSectors(ctx context.Context, hk types.PublicK return ssql.ResetLostSectors(ctx, tx, hk) } -func (tx MainDatabaseTx) SaveAccounts(ctx context.Context, owner string, accounts []api.Account) error { +func (tx MainDatabaseTx) SaveAccounts(ctx context.Context, accounts []api.Account) error { // clean_shutdown = 1 after save stmt, err := tx.Prepare(ctx, ` INSERT INTO ephemeral_accounts (created_at, account_id, clean_shutdown, host, balance, drift, requires_sync, owner) - VAlUES (?, ?, 1, ?, ?, ?, ?, ?) + VAlUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE account_id = VALUES(account_id), - clean_shutdown = 1, + clean_shutdown = VALUES(clean_shutdown), host = VALUES(host), balance = VALUES(balance), drift = VALUES(drift), @@ -735,7 +735,7 @@ func (tx MainDatabaseTx) SaveAccounts(ctx context.Context, owner string, account defer stmt.Close() for _, acc := range accounts { - res, err := stmt.Exec(ctx, time.Now(), (ssql.PublicKey)(acc.ID), (ssql.PublicKey)(acc.HostKey), (*ssql.BigInt)(acc.Balance), (*ssql.BigInt)(acc.Drift), acc.RequiresSync, owner) + res, err := stmt.Exec(ctx, time.Now(), (ssql.PublicKey)(acc.ID), acc.CleanShutdown, (ssql.PublicKey)(acc.HostKey), (*ssql.BigInt)(acc.Balance), (*ssql.BigInt)(acc.Drift), acc.RequiresSync, acc.Owner) if err != nil { return fmt.Errorf("failed to insert account %v: %w", acc.ID, err) } else if n, err := res.RowsAffected(); err != nil { diff --git a/stores/sql/sqlite/main.go b/stores/sql/sqlite/main.go index f26284966..e6bd4a0af 100644 --- a/stores/sql/sqlite/main.go +++ b/stores/sql/sqlite/main.go @@ -714,14 +714,14 @@ func (tx *MainDatabaseTx) ResetLostSectors(ctx context.Context, hk types.PublicK return ssql.ResetLostSectors(ctx, tx, hk) } -func (tx *MainDatabaseTx) SaveAccounts(ctx context.Context, owner string, accounts []api.Account) error { +func (tx *MainDatabaseTx) SaveAccounts(ctx context.Context, accounts []api.Account) error { // clean_shutdown = 1 after save stmt, err := tx.Prepare(ctx, ` INSERT INTO ephemeral_accounts (created_at, account_id, clean_shutdown, host, balance, drift, requires_sync, owner) - VAlUES (?, ?, 1, ?, ?, ?, ?, ?) + VAlUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET account_id = EXCLUDED.account_id, - clean_shutdown = 1, + clean_shutdown = EXCLUDED.clean_shutdown, host = EXCLUDED.host, balance = EXCLUDED.balance, drift = EXCLUDED.drift, @@ -733,7 +733,7 @@ func (tx *MainDatabaseTx) SaveAccounts(ctx context.Context, owner string, accoun defer stmt.Close() for _, acc := range accounts { - res, err := stmt.Exec(ctx, time.Now(), (ssql.PublicKey)(acc.ID), (ssql.PublicKey)(acc.HostKey), (*ssql.BigInt)(acc.Balance), (*ssql.BigInt)(acc.Drift), acc.RequiresSync, owner) + res, err := stmt.Exec(ctx, time.Now(), (ssql.PublicKey)(acc.ID), acc.CleanShutdown, (ssql.PublicKey)(acc.HostKey), (*ssql.BigInt)(acc.Balance), (*ssql.BigInt)(acc.Drift), acc.RequiresSync, acc.Owner) if err != nil { return fmt.Errorf("failed to insert account %v: %w", acc.ID, err) } else if n, err := res.RowsAffected(); err != nil { diff --git a/worker/mocks_test.go b/worker/mocks_test.go index 5da0dcc5a..417b6e7b2 100644 --- a/worker/mocks_test.go +++ b/worker/mocks_test.go @@ -25,7 +25,7 @@ func (*accountsMock) Accounts(context.Context, string) ([]api.Account, error) { return nil, nil } -func (*accountsMock) UpdateAccounts(context.Context, string, []api.Account, bool) error { +func (*accountsMock) UpdateAccounts(context.Context, []api.Account) error { return nil } From 27067f36dc7f03bba7688c55b012b85e1ef444ea Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Tue, 27 Aug 2024 15:33:18 +0200 Subject: [PATCH 38/41] worker: add reset drift endpoint for accounts --- worker/client/client.go | 6 ++++++ worker/worker.go | 22 +++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/worker/client/client.go b/worker/client/client.go index 3d8f25089..7df0a6052 100644 --- a/worker/client/client.go +++ b/worker/client/client.go @@ -44,6 +44,12 @@ func (c *Client) Accounts(ctx context.Context) (accounts []api.Account, err erro return } +// ResetDrift resets the drift of an account to zero. +func (c *Client) ResetDrift(ctx context.Context, id rhpv3.Account) (err error) { + err = c.c.WithContext(ctx).POST(fmt.Sprintf("/account/%s/resetdrift", id), nil, nil) + return +} + // Contracts returns all contracts from the worker. These contracts decorate a // bus contract with the contract's latest revision. func (c *Client) Contracts(ctx context.Context, hostTimeout time.Duration) (resp api.ContractsResponse, err error) { diff --git a/worker/worker.go b/worker/worker.go index 8adc9049b..7502f646b 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -1111,6 +1111,21 @@ func (w *Worker) accountsHandlerGET(jc jape.Context) { jc.Encode(w.accounts.Accounts()) } +func (w *Worker) accountsResetDriftHandlerPOST(jc jape.Context) { + var id rhpv3.Account + if jc.DecodeParam("id", &id) != nil { + return + } + err := w.accounts.ResetDrift(id) + if errors.Is(err, iworker.ErrAccountNotFound) { + jc.Error(err, http.StatusNotFound) + return + } + if jc.Check("failed to reset drift", err) != nil { + return + } +} + func (w *Worker) eventHandlerPOST(jc jape.Context) { var event webhooks.Event if jc.Decode(&event) != nil { @@ -1200,9 +1215,10 @@ func New(cfg config.Worker, masterKey [32]byte, b Bus, l *zap.Logger) (*Worker, // Handler returns an HTTP handler that serves the worker API. func (w *Worker) Handler() http.Handler { return jape.Mux(map[string]jape.Handler{ - "GET /accounts": w.accountsHandlerGET, - "GET /account/:hostkey": w.accountHandlerGET, - "GET /id": w.idHandlerGET, + "GET /accounts": w.accountsHandlerGET, + "GET /account/:hostkey": w.accountHandlerGET, + "POST /account/:id/resetdrift": w.accountsResetDriftHandlerPOST, + "GET /id": w.idHandlerGET, "POST /event": w.eventHandlerPOST, From 8ae5037c324978dc8ddcdb1419d2055f4470fc7e Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Wed, 28 Aug 2024 09:53:34 +0200 Subject: [PATCH 39/41] sql: remove redundant code --- stores/accounts.go | 10 ---------- stores/sql/database.go | 4 ---- stores/sql/main.go | 14 +++----------- stores/sql/mysql/main.go | 4 ---- stores/sql/sqlite/main.go | 4 ---- worker/mocks_test.go | 4 ---- 6 files changed, 3 insertions(+), 37 deletions(-) diff --git a/stores/accounts.go b/stores/accounts.go index 3fdbfaba7..ca5b70c7b 100644 --- a/stores/accounts.go +++ b/stores/accounts.go @@ -16,16 +16,6 @@ func (s *SQLStore) Accounts(ctx context.Context, owner string) (accounts []api.A return } -// SetUncleanShutdown sets the clean shutdown flag on the accounts to 'false' -// and also sets the 'requires_sync' flag. That way, the autopilot will know to -// sync all accounts after an unclean shutdown and the bus will know not to -// apply drift. -func (s *SQLStore) SetUncleanShutdown(ctx context.Context, owner string) error { - return s.db.Transaction(ctx, func(tx sql.DatabaseTx) error { - return tx.SetUncleanShutdown(ctx, owner) - }) -} - // SaveAccounts saves the given accounts in the db, overwriting any existing // ones. func (s *SQLStore) SaveAccounts(ctx context.Context, accounts []api.Account) error { diff --git a/stores/sql/database.go b/stores/sql/database.go index 342acd024..3e1917502 100644 --- a/stores/sql/database.go +++ b/stores/sql/database.go @@ -317,10 +317,6 @@ type ( // substring. SearchObjects(ctx context.Context, bucket, substring string, offset, limit int) ([]api.ObjectMetadata, error) - // SetUncleanShutdown sets the clean shutdown flag on the accounts to - // 'false' and also marks them as requiring a resync. - SetUncleanShutdown(ctx context.Context, owner string) error - // SetContractSet creates the contract set with the given name and // associates it with the provided contract IDs. SetContractSet(ctx context.Context, name string, contractIds []types.FileContractID) error diff --git a/stores/sql/main.go b/stores/sql/main.go index 90f8391d1..655859bfc 100644 --- a/stores/sql/main.go +++ b/stores/sql/main.go @@ -124,7 +124,7 @@ func Accounts(ctx context.Context, tx sql.Tx, owner string) ([]api.Account, erro func AncestorContracts(ctx context.Context, tx sql.Tx, fcid types.FileContractID, startHeight uint64) ([]api.ArchivedContract, error) { rows, err := tx.Query(ctx, ` - WITH RECURSIVE ancestors AS + WITH RECURSIVE ancestors AS ( SELECT * FROM archived_contracts @@ -722,7 +722,7 @@ func InsertContract(ctx context.Context, tx sql.Tx, rev rhpv2.ContractRevision, res, err := tx.Exec(ctx, ` INSERT INTO contracts (created_at, host_id, fcid, renewed_from, contract_price, state, total_cost, proof_height, - revision_height, revision_number, size, start_height, window_start, window_end, upload_spending, download_spending, + revision_height, revision_number, size, start_height, window_start, window_end, upload_spending, download_spending, fund_account_spending, delete_spending, list_spending) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, time.Now(), hostID, FileContractID(rev.ID()), FileContractID(renewedFrom), Currency(contractPrice), @@ -2236,20 +2236,12 @@ func Settings(ctx context.Context, tx sql.Tx) ([]string, error) { return settings, nil } -func SetUncleanShutdown(ctx context.Context, tx sql.Tx, owner string) error { - _, err := tx.Exec(ctx, "UPDATE ephemeral_accounts SET clean_shutdown = 0, requires_sync = 1 WHERE owner = ?", owner) - if err != nil { - return fmt.Errorf("failed to set unclean shutdown: %w", err) - } - return err -} - func Slab(ctx context.Context, tx sql.Tx, key object.EncryptionKey) (object.Slab, error) { // fetch slab var slabID int64 slab := object.Slab{Key: key} err := tx.QueryRow(ctx, ` - SELECT id, health, min_shards + SELECT id, health, min_shards FROM slabs sla WHERE sla.key = ? `, EncryptionKey(key)).Scan(&slabID, &slab.Health, &slab.MinShards) diff --git a/stores/sql/mysql/main.go b/stores/sql/mysql/main.go index 91b4d536d..de8b97bfa 100644 --- a/stores/sql/mysql/main.go +++ b/stores/sql/mysql/main.go @@ -827,10 +827,6 @@ func (tx *MainDatabaseTx) Settings(ctx context.Context) ([]string, error) { return ssql.Settings(ctx, tx) } -func (tx *MainDatabaseTx) SetUncleanShutdown(ctx context.Context, owner string) error { - return ssql.SetUncleanShutdown(ctx, tx, owner) -} - func (tx *MainDatabaseTx) Slab(ctx context.Context, key object.EncryptionKey) (object.Slab, error) { return ssql.Slab(ctx, tx, key) } diff --git a/stores/sql/sqlite/main.go b/stores/sql/sqlite/main.go index e6bd4a0af..739fb47f4 100644 --- a/stores/sql/sqlite/main.go +++ b/stores/sql/sqlite/main.go @@ -826,10 +826,6 @@ func (tx *MainDatabaseTx) Settings(ctx context.Context) ([]string, error) { return ssql.Settings(ctx, tx) } -func (tx *MainDatabaseTx) SetUncleanShutdown(ctx context.Context, owner string) error { - return ssql.SetUncleanShutdown(ctx, tx, owner) -} - func (tx *MainDatabaseTx) Slab(ctx context.Context, key object.EncryptionKey) (object.Slab, error) { return ssql.Slab(ctx, tx, key) } diff --git a/worker/mocks_test.go b/worker/mocks_test.go index 417b6e7b2..0b0d53351 100644 --- a/worker/mocks_test.go +++ b/worker/mocks_test.go @@ -29,10 +29,6 @@ func (*accountsMock) UpdateAccounts(context.Context, []api.Account) error { return nil } -func (*accountsMock) SetUncleanShutdown(context.Context, string) error { - return nil -} - var _ alerts.Alerter = (*alerterMock)(nil) type alerterMock struct{} From 0cd54a53f44bb6cf8ff3d708a24e89ef8fcff59b Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Wed, 28 Aug 2024 10:06:28 +0200 Subject: [PATCH 40/41] worker: interrupt shutdown on ctx --- internal/worker/accounts.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/worker/accounts.go b/internal/worker/accounts.go index 796863d7d..22f0dacc1 100644 --- a/internal/worker/accounts.go +++ b/internal/worker/accounts.go @@ -158,7 +158,17 @@ func (a *AccountMgr) Shutdown(ctx context.Context) error { a.logger.Infof("successfully saved %v accounts", len(accounts)) a.shutdownCancel() - a.wg.Wait() + + done := make(chan struct{}) + go func() { + a.wg.Wait() + close(done) + }() + select { + case <-ctx.Done(): + return errors.New("accountMgrShutdown interrupted") + case <-done: + } return nil } From 6a468950be0e03fc713643e9e461961a7c321229 Mon Sep 17 00:00:00 2001 From: Chris Schinnerl Date: Wed, 28 Aug 2024 10:24:10 +0200 Subject: [PATCH 41/41] accounts: wrap context.Cause --- internal/worker/accounts.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/worker/accounts.go b/internal/worker/accounts.go index 22f0dacc1..76613b757 100644 --- a/internal/worker/accounts.go +++ b/internal/worker/accounts.go @@ -166,7 +166,7 @@ func (a *AccountMgr) Shutdown(ctx context.Context) error { }() select { case <-ctx.Done(): - return errors.New("accountMgrShutdown interrupted") + return fmt.Errorf("accountMgrShutdown interrupted: %w", context.Cause(ctx)) case <-done: } return nil