Skip to content

Commit 1fdf892

Browse files
authored
feat: Offline licensing support (#1436)
BEGIN_COMMIT_OVERRIDE feat: Offline licensing support Includes breaking changes to the `premium.NewUsageClient` interface. Save value of `opts.PluginMeta` from `Configure()` and pass it to `premium.NewUsageClient` as the first argument. END_COMMIT_OVERRIDE --------- Co-authored-by: Kemal Hadimli <[email protected]>
1 parent 5642ead commit 1fdf892

File tree

10 files changed

+234
-48
lines changed

10 files changed

+234
-48
lines changed

examples/simple_plugin/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ require (
2121
github.com/CloudyKit/jet/v6 v6.2.0 // indirect
2222
github.com/Joker/jade v1.1.3 // indirect
2323
github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 // indirect
24+
github.com/adrg/xdg v0.4.0 // indirect
2425
github.com/andybalholm/brotli v1.0.6 // indirect
2526
github.com/apache/arrow/go/v13 v13.0.0-20230731205701-112f94971882 // indirect
2627
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect

examples/simple_plugin/go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKd
1111
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
1212
github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 h1:KkH3I3sJuOLP3TjA/dfr4NAY8bghDwnXiU7cTKxQqo0=
1313
github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06/go.mod h1:7erjKLwalezA0k99cWs5L11HWOAPNjdUZ6RxH1BXbbM=
14+
github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
15+
github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
1416
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
1517
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
1618
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
@@ -320,6 +322,7 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
320322
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
321323
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
322324
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
325+
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
323326
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
324327
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
325328
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

plugin/meta.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package plugin
2+
3+
import cqapi "github.com/cloudquery/cloudquery-api-go"
4+
5+
type Meta struct {
6+
Team cqapi.PluginTeam
7+
Kind cqapi.PluginKind
8+
Name cqapi.PluginName
9+
SkipUsageClient bool
10+
}

plugin/plugin.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"sync"
88

99
"github.com/apache/arrow/go/v15/arrow"
10+
cqapi "github.com/cloudquery/cloudquery-api-go"
1011
"github.com/cloudquery/plugin-sdk/v4/message"
1112
"github.com/cloudquery/plugin-sdk/v4/schema"
1213
"github.com/rs/zerolog"
@@ -17,6 +18,7 @@ var ErrNotImplemented = fmt.Errorf("not implemented")
1718

1819
type NewClientOptions struct {
1920
NoConnection bool
21+
PluginMeta Meta
2022
}
2123

2224
type NewClientFunc func(context.Context, zerolog.Logger, []byte, NewClientOptions) (Client, error)
@@ -78,6 +80,8 @@ type Plugin struct {
7880
schema string
7981
// validator object to validate specs
8082
schemaValidator *jsonschema.Schema
83+
// skips the usage client
84+
skipUsageClient bool
8185
}
8286

8387
// NewPlugin returns a new CloudQuery Plugin with the given name, version and implementation.
@@ -124,6 +128,11 @@ func (p *Plugin) Version() string {
124128
return p.version
125129
}
126130

131+
// SetSkipUsageClient sets whether the usage client should be skipped
132+
func (p *Plugin) SetSkipUsageClient(v bool) {
133+
p.skipUsageClient = v
134+
}
135+
127136
type OnBeforeSender interface {
128137
OnBeforeSend(context.Context, message.SyncMessage) (message.SyncMessage, error)
129138
}
@@ -196,6 +205,13 @@ func (p *Plugin) Init(ctx context.Context, spec []byte, options NewClientOptions
196205
}
197206
}
198207

208+
options.PluginMeta = Meta{
209+
Team: p.team,
210+
Kind: cqapi.PluginKind(p.kind),
211+
Name: p.name,
212+
SkipUsageClient: p.skipUsageClient,
213+
}
214+
199215
p.client, err = p.newClient(ctx, p.logger, spec, options)
200216
if err != nil {
201217
return fmt.Errorf("failed to initialize client: %w", err)

premium/offline.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package premium
2+
3+
import (
4+
"crypto/ed25519"
5+
_ "embed"
6+
"encoding/hex"
7+
"encoding/json"
8+
"errors"
9+
"os"
10+
"time"
11+
12+
"github.com/rs/zerolog"
13+
)
14+
15+
type License struct {
16+
LicensedTo string `json:"licensed_to"` // Customers name, e.g. "Acme Inc"
17+
IssuedAt time.Time `json:"issued_at"`
18+
ValidFrom time.Time `json:"valid_from"`
19+
ExpiresAt time.Time `json:"expires_at"`
20+
}
21+
22+
type LicenseWrapper struct {
23+
LicenseBytes []byte `json:"license"`
24+
Signature string `json:"signature"` // crypto
25+
}
26+
27+
var (
28+
ErrInvalidLicenseSignature = errors.New("invalid license signature")
29+
ErrLicenseNotValidYet = errors.New("license not valid yet")
30+
ErrLicenseExpired = errors.New("license expired")
31+
)
32+
33+
//go:embed offline.key
34+
var publicKey string
35+
36+
func ValidateLicense(logger zerolog.Logger, licenseFile string) error {
37+
licenseContents, err := os.ReadFile(licenseFile)
38+
if err != nil {
39+
return err
40+
}
41+
42+
l, err := UnpackLicense(licenseContents)
43+
if err != nil {
44+
return err
45+
}
46+
47+
return l.IsValid(logger)
48+
}
49+
50+
func UnpackLicense(lic []byte) (*License, error) {
51+
publicKeyBytes, err := hex.DecodeString(publicKey)
52+
if err != nil {
53+
return nil, err
54+
}
55+
56+
var lw LicenseWrapper
57+
if err := json.Unmarshal(lic, &lw); err != nil {
58+
return nil, err
59+
}
60+
61+
signatureBytes, err := hex.DecodeString(lw.Signature)
62+
if err != nil {
63+
return nil, err
64+
}
65+
66+
if !ed25519.Verify(publicKeyBytes, lw.LicenseBytes, signatureBytes) {
67+
return nil, ErrInvalidLicenseSignature
68+
}
69+
70+
var l License
71+
if err := json.Unmarshal(lw.LicenseBytes, &l); err != nil {
72+
return nil, err
73+
}
74+
75+
return &l, nil
76+
}
77+
78+
func (l *License) IsValid(logger zerolog.Logger) error {
79+
now := time.Now().UTC()
80+
if now.Before(l.ValidFrom) {
81+
return ErrLicenseNotValidYet
82+
}
83+
if now.After(l.ExpiresAt) {
84+
return ErrLicenseExpired
85+
}
86+
87+
msg := logger.Info()
88+
if now.Add(15 * 24 * time.Hour).After(l.ExpiresAt) {
89+
msg = logger.Warn()
90+
}
91+
92+
msg.Time("expires_at", l.ExpiresAt).Msgf("Offline license for %s loaded.", l.LicensedTo)
93+
return nil
94+
}

premium/offline.key

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
fd81a64351452e0ada99c05c7e44bdf104cc583eb3ed44bf5545fe82b2f0a615

premium/offline_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package premium
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestUnpackLicense(t *testing.T) {
11+
publicKey = "eacdff4866c8bc0d97de3c2d7668d0970c61aa16c3f12d6ba8083147ff92c9a6"
12+
13+
t.Run("Success", func(t *testing.T) {
14+
licData := `{"license":"eyJsaWNlbnNlZF90byI6IlVOTElDRU5TRUQgVEVTVCIsImlzc3VlZF9hdCI6IjIwMjMtMTItMjhUMTk6MDI6MjguODM4MzY3WiIsInZhbGlkX2Zyb20iOiIyMDIzLTEyLTI4VDE5OjAyOjI4LjgzODM2N1oiLCJleHBpcmVzX2F0IjoiMjAyMy0xMi0yOVQxOTowMjoyOC44MzgzNjdaIn0=","signature":"8687a858463764b052455b3c783d979d364b5fb653b86d88a7463e495480db62fdec7ae1a84d1e30dddee77eb769a0e498ecfc836538c53e410aeb1a0c04d102"}`
15+
16+
l, err := UnpackLicense([]byte(licData))
17+
require.NoError(t, err)
18+
require.Equal(t, "UNLICENSED TEST", l.LicensedTo)
19+
require.Equal(t, l.ExpiresAt.Add(-24*time.Hour).Truncate(time.Hour), l.ValidFrom.Truncate(time.Hour))
20+
})
21+
t.Run("Fail", func(t *testing.T) {
22+
licData := `{"license":"eyJsaWNlbnNlZF90byI6IlVOTElDRU5TRUQgVEVTVCIsImlzc3VlZF9hdCI6IjIwMjMtMTItMjhUMTk6MDI6MjguODM4MzY3WiIsInZhbGlkX2Zyb20iOiIyMDIzLTEyLTI4VDE5OjAyOjI4LjgzODM2N1oiLCJleHBpcmVzX2F0IjoiMjAyMy0xMi0yOVQxOTowMjoyOC44MzgzNjdaIn0=","signature":"9687a858463764b052455b3c783d979d364b5fb653b86d88a7463e495480db62fdec7ae1a84d1e30dddee77eb769a0e498ecfc836538c53e410aeb1a0c04d102"}`
23+
l, err := UnpackLicense([]byte(licData))
24+
require.ErrorIs(t, err, ErrInvalidLicenseSignature)
25+
require.Nil(t, l)
26+
})
27+
}

premium/usage.go

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
cqapi "github.com/cloudquery/cloudquery-api-go"
1414
"github.com/cloudquery/cloudquery-api-go/auth"
1515
"github.com/cloudquery/cloudquery-api-go/config"
16+
"github.com/cloudquery/plugin-sdk/v4/plugin"
1617
"github.com/google/uuid"
1718
"github.com/rs/zerolog"
1819
"github.com/rs/zerolog/log"
@@ -119,19 +120,10 @@ func withTokenClient(tokenClient TokenClient) UsageClientOptions {
119120
}
120121
}
121122

122-
func WithPluginTeam(pluginTeam string) cqapi.PluginTeam {
123-
return pluginTeam
124-
}
125-
126-
func WithPluginKind(pluginKind string) cqapi.PluginKind {
127-
return cqapi.PluginKind(pluginKind)
128-
}
129-
130-
func WithPluginName(pluginName string) cqapi.PluginName {
131-
return pluginName
132-
}
133-
134-
var _ UsageClient = (*BatchUpdater)(nil)
123+
var (
124+
_ UsageClient = (*BatchUpdater)(nil)
125+
_ UsageClient = (*NoOpUsageClient)(nil)
126+
)
135127

136128
type BatchUpdater struct {
137129
logger zerolog.Logger
@@ -141,9 +133,7 @@ type BatchUpdater struct {
141133

142134
// Plugin details
143135
teamName cqapi.TeamName
144-
pluginTeam cqapi.PluginTeam
145-
pluginKind cqapi.PluginKind
146-
pluginName cqapi.PluginName
136+
pluginMeta plugin.Meta
147137

148138
// Configuration
149139
batchLimit uint32
@@ -161,14 +151,12 @@ type BatchUpdater struct {
161151
isClosed bool
162152
}
163153

164-
func NewUsageClient(pluginTeam cqapi.PluginTeam, pluginKind cqapi.PluginKind, pluginName cqapi.PluginName, ops ...UsageClientOptions) (*BatchUpdater, error) {
154+
func NewUsageClient(meta plugin.Meta, ops ...UsageClientOptions) (UsageClient, error) {
165155
u := &BatchUpdater{
166156
logger: zerolog.Nop(),
167157
url: defaultAPIURL,
168158

169-
pluginTeam: pluginTeam,
170-
pluginKind: pluginKind,
171-
pluginName: pluginName,
159+
pluginMeta: meta,
172160

173161
batchLimit: defaultBatchLimit,
174162
minTimeBetweenFlushes: defaultMinTimeBetweenFlushes,
@@ -183,6 +171,13 @@ func NewUsageClient(pluginTeam cqapi.PluginTeam, pluginKind cqapi.PluginKind, pl
183171
op(u)
184172
}
185173

174+
if meta.SkipUsageClient {
175+
u.logger.Debug().Msg("Disabling usage client")
176+
return &NoOpUsageClient{
177+
TeamNameValue: u.teamName,
178+
}, nil
179+
}
180+
186181
if u.tokenClient == nil {
187182
u.tokenClient = auth.NewTokenClient()
188183
}
@@ -248,8 +243,8 @@ func (u *BatchUpdater) TeamName() string {
248243
}
249244

250245
func (u *BatchUpdater) HasQuota(ctx context.Context) (bool, error) {
251-
u.logger.Debug().Str("url", u.url).Str("team", u.teamName).Str("pluginTeam", u.pluginTeam).Str("pluginKind", string(u.pluginKind)).Str("pluginName", u.pluginName).Msg("checking quota")
252-
usage, err := u.apiClient.GetTeamPluginUsageWithResponse(ctx, u.teamName, u.pluginTeam, u.pluginKind, u.pluginName)
246+
u.logger.Debug().Str("url", u.url).Str("team", u.teamName).Str("pluginTeam", u.pluginMeta.Team).Str("pluginKind", string(u.pluginMeta.Kind)).Str("pluginName", u.pluginMeta.Name).Msg("checking quota")
247+
usage, err := u.apiClient.GetTeamPluginUsageWithResponse(ctx, u.teamName, u.pluginMeta.Team, u.pluginMeta.Kind, u.pluginMeta.Name)
253248
if err != nil {
254249
return false, fmt.Errorf("failed to get usage: %w", err)
255250
}
@@ -333,9 +328,9 @@ func (u *BatchUpdater) updateUsageWithRetryAndBackoff(ctx context.Context, numbe
333328

334329
resp, err := u.apiClient.IncreaseTeamPluginUsageWithResponse(ctx, u.teamName, cqapi.IncreaseTeamPluginUsageJSONRequestBody{
335330
RequestId: uuid.New(),
336-
PluginTeam: u.pluginTeam,
337-
PluginKind: u.pluginKind,
338-
PluginName: u.pluginName,
331+
PluginTeam: u.pluginMeta.Team,
332+
PluginKind: u.pluginMeta.Kind,
333+
PluginName: u.pluginMeta.Name,
339334
Rows: int(numberToUpdate),
340335
})
341336
if err != nil {
@@ -414,3 +409,23 @@ func (u *BatchUpdater) getTeamNameByTokenType(tokenType auth.TokenType) (string,
414409
return "", fmt.Errorf("unsupported token type: %v", tokenType)
415410
}
416411
}
412+
413+
type NoOpUsageClient struct {
414+
TeamNameValue string
415+
}
416+
417+
func (n *NoOpUsageClient) TeamName() string {
418+
return n.TeamNameValue
419+
}
420+
421+
func (NoOpUsageClient) HasQuota(_ context.Context) (bool, error) {
422+
return true, nil
423+
}
424+
425+
func (NoOpUsageClient) Increase(_ uint32) error {
426+
return nil
427+
}
428+
429+
func (NoOpUsageClient) Close() error {
430+
return nil
431+
}

0 commit comments

Comments
 (0)