feat(server): resolve licence state per instance
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// LicenseState is the resolved licence for one instance.
|
||||
type LicenseState struct {
|
||||
Status license.State `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Limits license.Limits `json:"limits"`
|
||||
Features map[string]bool `json:"features"`
|
||||
// Source is "stored", "env" or "none" — useful when a self-hosted operator
|
||||
// asks why the licence they pasted is not the one in effect.
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// Active reports whether mutations are allowed.
|
||||
func (s LicenseState) Active() bool { return s.Status == license.StateValid }
|
||||
|
||||
// Feature reports whether a named feature is granted.
|
||||
func (s LicenseState) Feature(name string) bool { return s.Features[name] }
|
||||
|
||||
// DeploymentMode is how this install describes itself to the verifier.
|
||||
//
|
||||
// It defaults to self_hosted, the stricter mode. An operator who removes the
|
||||
// variable gets the tighter behaviour, not the looser one.
|
||||
func DeploymentMode() string {
|
||||
if strings.ToLower(os.Getenv("VANTAGE_DEPLOYMENT")) == license.DeploymentCloud {
|
||||
return license.DeploymentCloud
|
||||
}
|
||||
return license.DeploymentSelfHosted
|
||||
}
|
||||
|
||||
type cachedLicense struct {
|
||||
state LicenseState
|
||||
at time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
licenseCacheMu sync.Mutex
|
||||
licenseCache = map[string]cachedLicense{}
|
||||
)
|
||||
|
||||
const licenseCacheTTL = 60 * time.Second
|
||||
|
||||
// InvalidateLicenseCache drops the cached state for one instance, so a pasted
|
||||
// licence takes effect immediately rather than within the TTL.
|
||||
func InvalidateLicenseCache(instanceID string) {
|
||||
licenseCacheMu.Lock()
|
||||
delete(licenseCache, instanceID)
|
||||
licenseCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// GetLicenseState resolves the licence for an instance, cached for 60 seconds.
|
||||
//
|
||||
// Resolution order:
|
||||
//
|
||||
// 1. the blob stored on the instance document
|
||||
// 2. VANTAGE_LICENSE, used ONLY when the instance has no stored blob, so an
|
||||
// automated self-hosted deployment can ship a licence without a human
|
||||
// pasting one
|
||||
// 3. neither -> invalid / no_license
|
||||
//
|
||||
// A blob stored through the UI always wins afterwards, so an operator is never
|
||||
// locked out by a stale environment value.
|
||||
func GetLicenseState(instanceID string) LicenseState {
|
||||
licenseCacheMu.Lock()
|
||||
if e, ok := licenseCache[instanceID]; ok && time.Since(e.at) < licenseCacheTTL {
|
||||
licenseCacheMu.Unlock()
|
||||
return e.state
|
||||
}
|
||||
licenseCacheMu.Unlock()
|
||||
|
||||
state := resolveLicenseState(instanceID)
|
||||
|
||||
licenseCacheMu.Lock()
|
||||
licenseCache[instanceID] = cachedLicense{state: state, at: time.Now()}
|
||||
licenseCacheMu.Unlock()
|
||||
return state
|
||||
}
|
||||
|
||||
func resolveLicenseState(instanceID string) LicenseState {
|
||||
inst, err := GetInstance(instanceID)
|
||||
if err != nil {
|
||||
return LicenseState{
|
||||
Status: license.StateInvalid,
|
||||
Reason: license.ReasonNoLicense,
|
||||
Features: map[string]bool{},
|
||||
Source: "none",
|
||||
}
|
||||
}
|
||||
|
||||
blob, source := inst.LicenseBlob, "stored"
|
||||
if blob == "" {
|
||||
blob, source = os.Getenv("VANTAGE_LICENSE"), "env"
|
||||
}
|
||||
if blob == "" {
|
||||
return LicenseState{
|
||||
Status: license.StateInvalid,
|
||||
Reason: license.ReasonNoLicense,
|
||||
Features: map[string]bool{},
|
||||
Source: "none",
|
||||
}
|
||||
}
|
||||
|
||||
res := license.Verify(blob, license.VerifyOpts{
|
||||
InstanceID: instanceID,
|
||||
Deployment: DeploymentMode(),
|
||||
})
|
||||
return stateFromResult(res, source)
|
||||
}
|
||||
|
||||
func stateFromResult(res license.Result, source string) LicenseState {
|
||||
feats := map[string]bool{}
|
||||
for _, f := range res.License.Features {
|
||||
feats[f] = true
|
||||
}
|
||||
s := LicenseState{
|
||||
Status: res.State,
|
||||
Reason: res.Reason,
|
||||
Tier: res.License.Tier,
|
||||
Limits: res.License.Limits,
|
||||
Features: feats,
|
||||
Source: source,
|
||||
}
|
||||
if !res.License.ExpiresAt.IsZero() {
|
||||
exp := res.License.ExpiresAt
|
||||
s.ExpiresAt = &exp
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// StoreLicense verifies a blob against this instance and stores it.
|
||||
//
|
||||
// An expired-but-otherwise-valid blob IS stored, so the UI can show what expired
|
||||
// and when. An invalid blob is rejected and the previous one kept.
|
||||
func StoreLicense(instanceID, blob string) (LicenseState, error) {
|
||||
blob = strings.TrimSpace(blob)
|
||||
|
||||
res := license.Verify(blob, license.VerifyOpts{
|
||||
InstanceID: instanceID,
|
||||
Deployment: DeploymentMode(),
|
||||
})
|
||||
if res.State == license.StateInvalid {
|
||||
return LicenseState{}, fmt.Errorf("%s", res.Reason)
|
||||
}
|
||||
|
||||
set := bson.M{
|
||||
"license_blob": blob,
|
||||
"license_tier": res.License.Tier,
|
||||
"license_expiry": res.License.ExpiresAt,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := db.Col("instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID}, bson.M{"$set": set}); err != nil {
|
||||
return LicenseState{}, err
|
||||
}
|
||||
|
||||
InvalidateLicenseCache(instanceID)
|
||||
return stateFromResult(res, "stored"), nil
|
||||
}
|
||||
Reference in New Issue
Block a user