feat(server): resolve licence state per instance

This commit is contained in:
2026-07-24 15:09:47 +01:00
parent f9f382049d
commit 1d3fcebb28
+175
View File
@@ -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
}