Skip to content

Commit 70434fc

Browse files
authored
refactor: add webapi service layer (#10)
Looking to write a new client for this and the first step is to ensure we can test it against an API backend. This PR decouples the database layer from the API allowing an API to be run in tests that uses a mock backend. This is mostly a mechanical change, however it does add a tested `spid` package that allows parsing and formatting a `FIL-SPID-V0` header. refs storacha/spade-agent#7 resolves #13
1 parent a0848ea commit 70434fc

29 files changed

+1943
-876
lines changed

.github/workflows/test.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# This workflow will build a golang project
2+
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
3+
4+
name: Test
5+
6+
on:
7+
push:
8+
branches: ['main']
9+
pull_request:
10+
branches: ['main']
11+
12+
jobs:
13+
test:
14+
name: Run tests
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v3
18+
19+
- name: Set up Go
20+
uses: actions/setup-go@v4
21+
with:
22+
go-version: '1.23'
23+
24+
- name: Test
25+
run: go test -v -failfast ./...

go.mod

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,12 @@ require (
3333
github.com/jackc/pgx/v4 v4.18.3
3434
github.com/jsign/go-filsigner v0.4.1
3535
github.com/labstack/echo/v4 v4.11.4
36+
github.com/libp2p/go-libp2p v0.42.0
37+
github.com/libp2p/go-yamux/v4 v4.0.2
3638
github.com/mattn/go-isatty v0.0.20
3739
github.com/multiformats/go-multiaddr v0.16.1
3840
github.com/multiformats/go-multihash v0.2.3
41+
github.com/stretchr/testify v1.10.0
3942
github.com/whyrusleeping/cbor-gen v0.3.1
4043
golang.org/x/sync v0.16.0
4144
golang.org/x/sys v0.35.0
@@ -70,6 +73,7 @@ require (
7073
github.com/cespare/xxhash/v2 v2.3.0 // indirect
7174
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
7275
github.com/daaku/go.zipexe v1.0.2 // indirect
76+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
7377
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect
7478
github.com/dchest/blake2b v1.0.0 // indirect
7579
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
@@ -150,13 +154,11 @@ require (
150154
github.com/lib/pq v1.10.9 // indirect
151155
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
152156
github.com/libp2p/go-flow-metrics v0.2.0 // indirect
153-
github.com/libp2p/go-libp2p v0.42.0 // indirect
154157
github.com/libp2p/go-libp2p-asn-util v0.4.1 // indirect
155158
github.com/libp2p/go-libp2p-pubsub v0.13.0 // indirect
156159
github.com/libp2p/go-msgio v0.3.0 // indirect
157160
github.com/libp2p/go-netroute v0.2.2 // indirect
158161
github.com/libp2p/go-reuseport v0.4.0 // indirect
159-
github.com/libp2p/go-yamux/v4 v4.0.2 // indirect
160162
github.com/libp2p/go-yamux/v5 v5.0.1 // indirect
161163
github.com/magefile/mage v1.15.0 // indirect
162164
github.com/mailru/easyjson v0.7.7 // indirect
@@ -200,6 +202,7 @@ require (
200202
github.com/pion/turn/v4 v4.0.2 // indirect
201203
github.com/pion/webrtc/v4 v4.1.2 // indirect
202204
github.com/pkg/errors v0.9.1 // indirect
205+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
203206
github.com/polydawn/refmt v0.89.0 // indirect
204207
github.com/prometheus/client_golang v1.22.0 // indirect
205208
github.com/prometheus/client_model v0.6.2 // indirect

service/constants.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package service
2+
3+
const (
4+
ListEligibleDefaultSize = 500
5+
ListEligibleMaxSize = 2 << 20
6+
ShowRecentFailuresHours = 24
7+
)

service/lotus.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package service
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"code.riba.cloud/go/toolbox-interplanetary/fil"
9+
"code.riba.cloud/go/toolbox/cmn"
10+
"github.com/filecoin-project/go-address"
11+
"github.com/filecoin-project/go-state-types/abi"
12+
filabi "github.com/filecoin-project/go-state-types/abi"
13+
filbig "github.com/filecoin-project/go-state-types/big"
14+
filbuiltin "github.com/filecoin-project/go-state-types/builtin"
15+
"github.com/filecoin-project/lotus/api"
16+
"github.com/filecoin-project/lotus/chain/types"
17+
lru "github.com/hashicorp/golang-lru/v2"
18+
"github.com/storacha/spade/internal/app"
19+
)
20+
21+
// AuthorizationLotusClient defines the minimal Filecoin client interface
22+
// required to support SP authorization.
23+
type AuthorizationLotusClient interface {
24+
// ChainGetTipSetByHeight looks back for a tipset at the specified epoch.
25+
ChainGetTipSetByHeight(context.Context, abi.ChainEpoch, types.TipSetKey) (*types.TipSet, error)
26+
// StateAccountKey retrieves the key address for an account at a given tipset.
27+
StateAccountKey(context.Context, address.Address, types.TipSetKey) (address.Address, error)
28+
// StateMinerInfo retrieves miner info at a given tipset.
29+
StateMinerInfo(context.Context, address.Address, types.TipSetKey) (api.MinerInfo, error)
30+
}
31+
32+
// LookbackLotusClient defines the minimal Filecoin client interface required to
33+
// support lookback tipset retrieval.
34+
type LookbackLotusClient interface {
35+
// ChainHead returns the current head of the chain.
36+
ChainHead(context.Context) (*types.TipSet, error)
37+
// ChainGetTipSetByHeight looks back for a tipset at the specified epoch.
38+
ChainGetTipSetByHeight(context.Context, abi.ChainEpoch, types.TipSetKey) (*types.TipSet, error)
39+
}
40+
41+
// ReservationLotusClient defines the minimal Filecoin client interface required
42+
// to support reservation eligibility checks and related operations.
43+
type ReservationLotusClient interface {
44+
LookbackLotusClient
45+
// MinerGetBaseInfo retrieves mining base info for a miner at a given tipset.
46+
MinerGetBaseInfo(context.Context, address.Address, abi.ChainEpoch, types.TipSetKey) (*api.MiningBaseInfo, error)
47+
}
48+
49+
// SpadeLotusClient defines the minimal Lotus client interface required to
50+
// support all Spade service operations.
51+
type SpadeLotusClient interface {
52+
AuthorizationLotusClient
53+
ReservationLotusClient
54+
}
55+
56+
var collateralCache, _ = lru.New[filabi.ChainEpoch, filbig.Int](128)
57+
58+
func ProviderCollateralEstimateGiB(ctx context.Context, sourceEpoch filabi.ChainEpoch) (filbig.Int, error) {
59+
if pc, didFind := collateralCache.Get(sourceEpoch); didFind {
60+
return pc, nil
61+
}
62+
63+
collateralGiB, err := app.EpochMinProviderCollateralEstimateGiB(ctx, sourceEpoch)
64+
if err != nil {
65+
return collateralGiB, cmn.WrErr(err)
66+
}
67+
68+
// make it 1.7 times larger, so that fluctuations in the state won't prevent the deal from being proposed/published later
69+
// capped by https://github.com/filecoin-project/lotus/blob/v1.13.2-rc2/markets/storageadapter/provider.go#L267
70+
// and https://github.com/filecoin-project/lotus/blob/v1.13.2-rc2/markets/storageadapter/provider.go#L41
71+
inflatedCollateralGiB := filbig.Div(
72+
filbig.Product(
73+
collateralGiB,
74+
filbig.NewInt(17),
75+
),
76+
filbig.NewInt(10),
77+
)
78+
79+
collateralCache.Add(sourceEpoch, inflatedCollateralGiB)
80+
return inflatedCollateralGiB, nil
81+
}
82+
83+
// GetTipset retrieves the tipset at the specified lookback epoch. It is a
84+
// copy of [fil.GetTipset] adjusted to use the minimal interface
85+
// [LookbackLotusClient].
86+
func GetTipset(ctx context.Context, lapi LookbackLotusClient, lookback uint) (*fil.LotusTS, error) {
87+
latestHead, err := lapi.ChainHead(ctx)
88+
if err != nil {
89+
return nil, fmt.Errorf("failed getting chain head: %w", err)
90+
}
91+
92+
wallUnix := time.Now().Unix()
93+
filUnix := int64(latestHead.Blocks()[0].Timestamp)
94+
95+
if wallUnix < filUnix-3 || // allow few seconds clock-drift tolerance
96+
wallUnix > filUnix+int64(
97+
fil.PropagationDelaySecs+(fil.APIMaxTipsetsBehind*filbuiltin.EpochDurationSeconds),
98+
) {
99+
return nil, fmt.Errorf(
100+
"lotus API out of sync: chainHead reports unixtime %d (height: %d) while walltime is %d (delta: %s)",
101+
filUnix,
102+
latestHead.Height(),
103+
wallUnix,
104+
time.Second*time.Duration(wallUnix-filUnix),
105+
)
106+
}
107+
108+
if lookback == 0 {
109+
return latestHead, nil
110+
}
111+
112+
latestHeight := latestHead.Height()
113+
tipsetAtLookback, err := lapi.ChainGetTipSetByHeight(ctx, latestHeight-filabi.ChainEpoch(lookback), latestHead.Key())
114+
if err != nil {
115+
return nil, fmt.Errorf("determining target tipset %d epochs ago failed: %w", lookback, err)
116+
}
117+
118+
return tipsetAtLookback, nil
119+
}

service/pg/authorization.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package pg
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
"time"
10+
11+
"code.riba.cloud/go/toolbox-interplanetary/fil"
12+
"github.com/google/uuid"
13+
"github.com/storacha/spade/apitypes"
14+
"github.com/storacha/spade/service"
15+
"github.com/storacha/spade/spid"
16+
spid_lotus "github.com/storacha/spade/spid/lotus"
17+
)
18+
19+
func (p *PgLotusSpadeService) Authorize(ctx context.Context, req service.Request) (service.Authorization, error) {
20+
challenge, err := spid.Parse(req.Headers.Get("Authorization"))
21+
if err != nil {
22+
return service.Authorization{}, fmt.Errorf("parsing authorization header: %w", err)
23+
}
24+
25+
err = spid_lotus.ResolveAndVerify(ctx, p.lotusAPI, challenge)
26+
if err != nil {
27+
return service.Authorization{}, fmt.Errorf("verifying authorization signature: %w", err)
28+
}
29+
30+
sp := fil.MustParseActorString(challenge.Addr().String())
31+
signedArgs, err := challenge.Args().Values()
32+
if err != nil {
33+
return service.Authorization{}, fmt.Errorf("getting signed args values: %w", err)
34+
}
35+
36+
reqJ, err := json.Marshal(
37+
struct {
38+
Method string
39+
Host string
40+
Path string
41+
Params string
42+
ParamsSigned url.Values
43+
Headers http.Header
44+
}{
45+
Method: req.Method,
46+
Host: req.Host,
47+
Path: req.Path,
48+
Params: req.Params.Encode(),
49+
ParamsSigned: signedArgs,
50+
Headers: req.Headers,
51+
},
52+
)
53+
if err != nil {
54+
return service.Authorization{}, err
55+
}
56+
57+
spDetails := [4]int16{-1, -1, -1, -1}
58+
var requestUUID string
59+
var stateEpoch int64
60+
var spInfo apitypes.SPInfo
61+
var spInfoLastPoll *time.Time
62+
if err := p.db.QueryRow(
63+
ctx,
64+
`
65+
INSERT INTO spd.requests ( provider_id, request_dump )
66+
VALUES ( $1, $2 )
67+
RETURNING
68+
request_uuid,
69+
( SELECT ( metadata->'market_state'->'epoch' )::INTEGER FROM spd.global ),
70+
COALESCE( (
71+
SELECT
72+
ARRAY[
73+
COALESCE( org_id, -1 ),
74+
COALESCE( city_id, -1),
75+
COALESCE( country_id, -1),
76+
COALESCE( continent_id, -1)
77+
]
78+
FROM spd.providers
79+
WHERE provider_id = $1
80+
LIMIT 1
81+
), ARRAY[-1, -1, -1, -1] ),
82+
(
83+
SELECT info
84+
FROM spd.providers_info
85+
WHERE provider_id = $1
86+
),
87+
(
88+
SELECT provider_last_polled
89+
FROM spd.providers_info
90+
WHERE provider_id = $1
91+
)
92+
`,
93+
sp,
94+
reqJ,
95+
).Scan(&requestUUID, &stateEpoch, &spDetails, &spInfo, &spInfoLastPoll); err != nil {
96+
return service.Authorization{}, err
97+
}
98+
99+
reqID, err := uuid.Parse(requestUUID)
100+
if err != nil {
101+
return service.Authorization{}, fmt.Errorf("parsing UUID: %w", err)
102+
}
103+
104+
return service.Authorization{
105+
RequestID: reqID,
106+
StateEpoch: stateEpoch,
107+
SignedArgs: signedArgs,
108+
ProviderID: sp,
109+
ProviderDetails: spDetails,
110+
ProviderInfo: spInfo,
111+
LastPoll: spInfoLastPoll,
112+
}, nil
113+
}

service/pg/eligibility.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package pg
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"code.riba.cloud/go/toolbox-interplanetary/fil"
8+
"github.com/georgysavva/scany/pgxscan"
9+
"github.com/storacha/spade/service"
10+
)
11+
12+
func (p *PgLotusSpadeService) EligiblePieces(ctx context.Context, sp fil.ActorID, options ...service.EligiblePiecesOption) ([]service.EligiblePiece, bool, error) {
13+
cfg := service.EligiblePiecesConfig{Limit: service.ListEligibleDefaultSize}
14+
for _, opt := range options {
15+
opt(&cfg)
16+
}
17+
18+
lim := cfg.Limit
19+
tenantID := cfg.TenantID
20+
// how to list: start small, find setting below
21+
useQueryFunc := "pieces_eligible_head"
22+
if lim > service.ListEligibleDefaultSize { // deduce from requested lim
23+
useQueryFunc = "pieces_eligible_full"
24+
}
25+
26+
orderedPieces := make([]service.EligiblePiece, 0, lim+1)
27+
if err := pgxscan.Select(
28+
ctx,
29+
p.db,
30+
&orderedPieces,
31+
fmt.Sprintf("SELECT * FROM spd.%s( $1, $2, $3, $4, $5 )", useQueryFunc),
32+
sp,
33+
lim+1, // ask for one extra, to disambiguate "there is more"
34+
tenantID,
35+
cfg.IncludeSourceless,
36+
false,
37+
); err != nil {
38+
return nil, false, err
39+
}
40+
41+
var more bool
42+
if uint64(len(orderedPieces)) > lim {
43+
orderedPieces = orderedPieces[:lim]
44+
more = true
45+
}
46+
return orderedPieces, more, nil
47+
}

service/pg/errorlogger.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package pg
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
7+
"github.com/google/uuid"
8+
"github.com/storacha/spade/apitypes"
9+
)
10+
11+
func (s *PgLotusSpadeService) RequestError(ctx context.Context, requestID uuid.UUID, code apitypes.APIErrorCode, message string, payload any) error {
12+
jPayload, err := json.Marshal(payload)
13+
if err != nil {
14+
return err
15+
}
16+
_, err = s.db.Exec(
17+
ctx,
18+
`
19+
UPDATE spd.requests SET
20+
request_meta = JSONB_STRIP_NULLS( request_meta || JSONB_BUILD_OBJECT(
21+
'error', $1::TEXT,
22+
'error_code', $2::INTEGER,
23+
'error_slug', $3::TEXT,
24+
'payload', $4::JSONB
25+
) )
26+
WHERE
27+
request_uuid = $5
28+
`,
29+
message,
30+
code,
31+
code.String(),
32+
jPayload,
33+
requestID.String(),
34+
)
35+
return err
36+
}

0 commit comments

Comments
 (0)