diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index 82557705fd..4291b3f488 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -40,6 +40,7 @@ type APIHandlerOptions struct { // Querier Influx Interval FluxInterval time.Duration UseLogsNewSchema bool + UseLicensesV3 bool } type APIHandler struct { @@ -65,6 +66,7 @@ func NewAPIHandler(opts APIHandlerOptions) (*APIHandler, error) { Cache: opts.Cache, FluxInterval: opts.FluxInterval, UseLogsNewSchema: opts.UseLogsNewSchema, + UseLicensesV3: opts.UseLicensesV3, }) if err != nil { @@ -173,10 +175,25 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *baseapp.AuthMiddlew router.HandleFunc("/api/v1/dashboards/{uuid}/lock", am.EditAccess(ah.lockDashboard)).Methods(http.MethodPut) router.HandleFunc("/api/v1/dashboards/{uuid}/unlock", am.EditAccess(ah.unlockDashboard)).Methods(http.MethodPut) + // v2 router.HandleFunc("/api/v2/licenses", am.ViewAccess(ah.listLicensesV2)). Methods(http.MethodGet) + // v3 + router.HandleFunc("/api/v3/licenses", + am.ViewAccess(ah.listLicensesV3)). + Methods(http.MethodGet) + + router.HandleFunc("/api/v3/licenses", + am.AdminAccess(ah.applyLicenseV3)). + Methods(http.MethodPost) + + router.HandleFunc("/api/v3/licenses", + am.AdminAccess(ah.refreshLicensesV3)). + Methods(http.MethodPut) + + // v4 router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost) // Gateway diff --git a/ee/query-service/app/api/license.go b/ee/query-service/app/api/license.go index 51cfddefb1..0cb7fa2bab 100644 --- a/ee/query-service/app/api/license.go +++ b/ee/query-service/app/api/license.go @@ -9,6 +9,7 @@ import ( "go.signoz.io/signoz/ee/query-service/constants" "go.signoz.io/signoz/ee/query-service/model" + "go.signoz.io/signoz/pkg/http/render" "go.uber.org/zap" ) @@ -59,6 +60,21 @@ type billingDetails struct { } `json:"data"` } +type ApplyLicenseRequest struct { + LicenseKey string `json:"key"` +} + +type ListLicenseResponse map[string]interface{} + +func convertLicenseV3ToListLicenseResponse(licensesV3 []*model.LicenseV3) []ListLicenseResponse { + listLicenses := []ListLicenseResponse{} + + for _, license := range licensesV3 { + listLicenses = append(listLicenses, license.Data) + } + return listLicenses +} + func (ah *APIHandler) listLicenses(w http.ResponseWriter, r *http.Request) { licenses, apiError := ah.LM().GetLicenses(context.Background()) if apiError != nil { @@ -88,6 +104,51 @@ func (ah *APIHandler) applyLicense(w http.ResponseWriter, r *http.Request) { ah.Respond(w, license) } +func (ah *APIHandler) listLicensesV3(w http.ResponseWriter, r *http.Request) { + licenses, apiError := ah.LM().GetLicensesV3(r.Context()) + + if apiError != nil { + RespondError(w, apiError, nil) + return + } + + ah.Respond(w, convertLicenseV3ToListLicenseResponse(licenses)) +} + +// this function is called by zeus when inserting licenses in the query-service +func (ah *APIHandler) applyLicenseV3(w http.ResponseWriter, r *http.Request) { + var licenseKey ApplyLicenseRequest + + if err := json.NewDecoder(r.Body).Decode(&licenseKey); err != nil { + RespondError(w, model.BadRequest(err), nil) + return + } + + if licenseKey.LicenseKey == "" { + RespondError(w, model.BadRequest(fmt.Errorf("license key is required")), nil) + return + } + + _, apiError := ah.LM().ActivateV3(r.Context(), licenseKey.LicenseKey) + if apiError != nil { + RespondError(w, apiError, nil) + return + } + + render.Success(w, http.StatusAccepted, nil) +} + +func (ah *APIHandler) refreshLicensesV3(w http.ResponseWriter, r *http.Request) { + + apiError := ah.LM().RefreshLicense(r.Context()) + if apiError != nil { + RespondError(w, apiError, nil) + return + } + + render.Success(w, http.StatusNoContent, nil) +} + func (ah *APIHandler) checkout(w http.ResponseWriter, r *http.Request) { type checkoutResponse struct { @@ -154,11 +215,45 @@ func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) { ah.Respond(w, billingResponse.Data) } +func convertLicenseV3ToLicenseV2(licenses []*model.LicenseV3) []model.License { + licensesV2 := []model.License{} + for _, l := range licenses { + licenseV2 := model.License{ + Key: l.Key, + ActivationId: "", + PlanDetails: "", + FeatureSet: l.Features, + ValidationMessage: "", + IsCurrent: l.IsCurrent, + LicensePlan: model.LicensePlan{ + PlanKey: l.PlanName, + ValidFrom: l.ValidFrom, + ValidUntil: l.ValidUntil, + Status: l.Status}, + } + licensesV2 = append(licensesV2, licenseV2) + } + return licensesV2 +} + func (ah *APIHandler) listLicensesV2(w http.ResponseWriter, r *http.Request) { - licenses, apiError := ah.LM().GetLicenses(context.Background()) - if apiError != nil { - RespondError(w, apiError, nil) + var licenses []model.License + + if ah.UseLicensesV3 { + licensesV3, err := ah.LM().GetLicensesV3(r.Context()) + if err != nil { + RespondError(w, err, nil) + return + } + licenses = convertLicenseV3ToLicenseV2(licensesV3) + } else { + _licenses, apiError := ah.LM().GetLicenses(r.Context()) + if apiError != nil { + RespondError(w, apiError, nil) + return + } + licenses = _licenses } resp := model.Licenses{ diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index 1c44338a77..a8acbc46e9 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -77,6 +77,7 @@ type ServerOptions struct { Cluster string GatewayUrl string UseLogsNewSchema bool + UseLicensesV3 bool } // Server runs HTTP api service @@ -133,7 +134,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { } // initiate license manager - lm, err := licensepkg.StartManager("sqlite", localDB) + lm, err := licensepkg.StartManager("sqlite", localDB, serverOptions.UseLicensesV3) if err != nil { return nil, err } @@ -269,6 +270,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { FluxInterval: fluxInterval, Gateway: gatewayProxy, UseLogsNewSchema: serverOptions.UseLogsNewSchema, + UseLicensesV3: serverOptions.UseLicensesV3, } apiHandler, err := api.NewAPIHandler(apiOpts) diff --git a/ee/query-service/constants/constants.go b/ee/query-service/constants/constants.go index c1baa6320b..0931fd01fc 100644 --- a/ee/query-service/constants/constants.go +++ b/ee/query-service/constants/constants.go @@ -13,6 +13,7 @@ var LicenseAPIKey = GetOrDefaultEnv("SIGNOZ_LICENSE_API_KEY", "") var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "") var FetchFeatures = GetOrDefaultEnv("FETCH_FEATURES", "false") var ZeusFeaturesURL = GetOrDefaultEnv("ZEUS_FEATURES_URL", "ZeusFeaturesURL") +var ZeusURL = GetOrDefaultEnv("ZEUS_URL", "ZeusURL") func GetOrDefaultEnv(key string, fallback string) string { v := os.Getenv(key) diff --git a/ee/query-service/integrations/signozio/response.go b/ee/query-service/integrations/signozio/response.go index 67ad8aac88..f0b0132d1b 100644 --- a/ee/query-service/integrations/signozio/response.go +++ b/ee/query-service/integrations/signozio/response.go @@ -13,3 +13,8 @@ type ActivationResponse struct { ActivationId string `json:"ActivationId"` PlanDetails string `json:"PlanDetails"` } + +type ValidateLicenseResponse struct { + Status status `json:"status"` + Data map[string]interface{} `json:"data"` +} diff --git a/ee/query-service/integrations/signozio/signozio.go b/ee/query-service/integrations/signozio/signozio.go index c18cfb6572..6c0b937c80 100644 --- a/ee/query-service/integrations/signozio/signozio.go +++ b/ee/query-service/integrations/signozio/signozio.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "time" "github.com/pkg/errors" "go.uber.org/zap" @@ -23,12 +24,14 @@ const ( ) type Client struct { - Prefix string + Prefix string + GatewayUrl string } func New() *Client { return &Client{ - Prefix: constants.LicenseSignozIo, + Prefix: constants.LicenseSignozIo, + GatewayUrl: constants.ZeusURL, } } @@ -116,6 +119,60 @@ func ValidateLicense(activationId string) (*ActivationResponse, *model.ApiError) } +func ValidateLicenseV3(licenseKey string) (*model.LicenseV3, *model.ApiError) { + + // Creating an HTTP client with a timeout for better control + client := &http.Client{ + Timeout: 10 * time.Second, + } + + req, err := http.NewRequest("GET", C.GatewayUrl+"/v2/licenses/me", nil) + if err != nil { + return nil, model.BadRequest(errors.Wrap(err, fmt.Sprintf("failed to create request: %w", err))) + } + + // Setting the custom header + req.Header.Set("X-Signoz-Cloud-Api-Key", licenseKey) + + response, err := client.Do(req) + if err != nil { + return nil, model.BadRequest(errors.Wrap(err, fmt.Sprintf("failed to make post request: %w", err))) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, model.BadRequest(errors.Wrap(err, fmt.Sprintf("failed to read validation response from %v", C.GatewayUrl))) + } + + defer response.Body.Close() + + switch response.StatusCode { + case 200: + a := ValidateLicenseResponse{} + err = json.Unmarshal(body, &a) + if err != nil { + return nil, model.BadRequest(errors.Wrap(err, "failed to marshal license validation response")) + } + + license, err := model.NewLicenseV3(a.Data) + if err != nil { + return nil, model.BadRequest(errors.Wrap(err, "failed to generate new license v3")) + } + + return license, nil + case 400: + return nil, model.BadRequest(errors.Wrap(fmt.Errorf(string(body)), + fmt.Sprintf("bad request error received from %v", C.GatewayUrl))) + case 401: + return nil, model.Unauthorized(errors.Wrap(fmt.Errorf(string(body)), + fmt.Sprintf("unauthorized request error received from %v", C.GatewayUrl))) + default: + return nil, model.InternalError(errors.Wrap(fmt.Errorf(string(body)), + fmt.Sprintf("internal request error received from %v", C.GatewayUrl))) + } + +} + func NewPostRequestWithCtx(ctx context.Context, url string, contentType string, body io.Reader) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, POST, url, body) if err != nil { diff --git a/ee/query-service/license/db.go b/ee/query-service/license/db.go index f6ccc88426..12df69233d 100644 --- a/ee/query-service/license/db.go +++ b/ee/query-service/license/db.go @@ -3,6 +3,7 @@ package license import ( "context" "database/sql" + "encoding/json" "fmt" "time" @@ -48,6 +49,34 @@ func (r *Repo) GetLicenses(ctx context.Context) ([]model.License, error) { return licenses, nil } +func (r *Repo) GetLicensesV3(ctx context.Context) ([]*model.LicenseV3, error) { + licensesData := []model.LicenseDB{} + licenseV3Data := []*model.LicenseV3{} + + query := "SELECT id,key,data FROM licenses_v3" + + err := r.db.Select(&licensesData, query) + if err != nil { + return nil, fmt.Errorf("failed to get licenses from db: %v", err) + } + + for _, l := range licensesData { + var licenseData map[string]interface{} + err := json.Unmarshal([]byte(l.Data), &licenseData) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data into licenseData : %v", err) + } + + license, err := model.NewLicenseV3WithIDAndKey(l.ID, l.Key, licenseData) + if err != nil { + return nil, fmt.Errorf("failed to get licenses v3 schema : %v", err) + } + licenseV3Data = append(licenseV3Data, license) + } + + return licenseV3Data, nil +} + // GetActiveLicense fetches the latest active license from DB. // If the license is not present, expect a nil license and a nil error in the output. func (r *Repo) GetActiveLicense(ctx context.Context) (*model.License, *basemodel.ApiError) { @@ -79,6 +108,45 @@ func (r *Repo) GetActiveLicense(ctx context.Context) (*model.License, *basemodel return active, nil } +func (r *Repo) GetActiveLicenseV3(ctx context.Context) (*model.LicenseV3, error) { + var err error + licenses := []model.LicenseDB{} + + query := "SELECT id,key,data FROM licenses_v3" + + err = r.db.Select(&licenses, query) + if err != nil { + return nil, basemodel.InternalError(fmt.Errorf("failed to get active licenses from db: %v", err)) + } + + var active *model.LicenseV3 + for _, l := range licenses { + var licenseData map[string]interface{} + err := json.Unmarshal([]byte(l.Data), &licenseData) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data into licenseData : %v", err) + } + + license, err := model.NewLicenseV3WithIDAndKey(l.ID, l.Key, licenseData) + if err != nil { + return nil, fmt.Errorf("failed to get licenses v3 schema : %v", err) + } + + if active == nil && + (license.ValidFrom != 0) && + (license.ValidUntil == -1 || license.ValidUntil > time.Now().Unix()) { + active = license + } + if active != nil && + license.ValidFrom > active.ValidFrom && + (license.ValidUntil == -1 || license.ValidUntil > time.Now().Unix()) { + active = license + } + } + + return active, nil +} + // InsertLicense inserts a new license in db func (r *Repo) InsertLicense(ctx context.Context, l *model.License) error { @@ -204,3 +272,53 @@ func (r *Repo) InitFeatures(req basemodel.FeatureSet) error { } return nil } + +// InsertLicenseV3 inserts a new license v3 in db +func (r *Repo) InsertLicenseV3(ctx context.Context, l *model.LicenseV3) error { + + query := `INSERT INTO licenses_v3 (id, key, data) VALUES ($1, $2, $3)` + + // licsense is the entity of zeus so putting the entire license here without defining schema + licenseData, err := json.Marshal(l.Data) + if err != nil { + return fmt.Errorf("insert license failed: license marshal error") + } + + _, err = r.db.ExecContext(ctx, + query, + l.ID, + l.Key, + string(licenseData), + ) + + if err != nil { + zap.L().Error("error in inserting license data: ", zap.Error(err)) + return fmt.Errorf("failed to insert license in db: %v", err) + } + + return nil +} + +// UpdateLicenseV3 updates a new license v3 in db +func (r *Repo) UpdateLicenseV3(ctx context.Context, l *model.LicenseV3) error { + + // the key and id for the license can't change so only update the data here! + query := `UPDATE licenses_v3 SET data=$1 WHERE id=$2;` + + license, err := json.Marshal(l.Data) + if err != nil { + return fmt.Errorf("insert license failed: license marshal error") + } + _, err = r.db.ExecContext(ctx, + query, + license, + l.ID, + ) + + if err != nil { + zap.L().Error("error in updating license data: ", zap.Error(err)) + return fmt.Errorf("failed to update license in db: %v", err) + } + + return nil +} diff --git a/ee/query-service/license/manager.go b/ee/query-service/license/manager.go index 800f4b7ff3..13b869da8c 100644 --- a/ee/query-service/license/manager.go +++ b/ee/query-service/license/manager.go @@ -7,6 +7,7 @@ import ( "time" "github.com/jmoiron/sqlx" + "github.com/pkg/errors" "sync" @@ -45,11 +46,12 @@ type Manager struct { failedAttempts uint64 // keep track of active license and features - activeLicense *model.License - activeFeatures basemodel.FeatureSet + activeLicense *model.License + activeLicenseV3 *model.LicenseV3 + activeFeatures basemodel.FeatureSet } -func StartManager(dbType string, db *sqlx.DB, features ...basemodel.Feature) (*Manager, error) { +func StartManager(dbType string, db *sqlx.DB, useLicensesV3 bool, features ...basemodel.Feature) (*Manager, error) { if LM != nil { return LM, nil } @@ -65,7 +67,7 @@ func StartManager(dbType string, db *sqlx.DB, features ...basemodel.Feature) (*M repo: &repo, } - if err := m.start(features...); err != nil { + if err := m.start(useLicensesV3, features...); err != nil { return m, err } LM = m @@ -73,8 +75,14 @@ func StartManager(dbType string, db *sqlx.DB, features ...basemodel.Feature) (*M } // start loads active license in memory and initiates validator -func (lm *Manager) start(features ...basemodel.Feature) error { - err := lm.LoadActiveLicense(features...) +func (lm *Manager) start(useLicensesV3 bool, features ...basemodel.Feature) error { + + var err error + if useLicensesV3 { + err = lm.LoadActiveLicenseV3(features...) + } else { + err = lm.LoadActiveLicense(features...) + } return err } @@ -108,6 +116,31 @@ func (lm *Manager) SetActive(l *model.License, features ...basemodel.Feature) { go lm.Validator(context.Background()) } +} +func (lm *Manager) SetActiveV3(l *model.LicenseV3, features ...basemodel.Feature) { + lm.mutex.Lock() + defer lm.mutex.Unlock() + + if l == nil { + return + } + + lm.activeLicenseV3 = l + lm.activeFeatures = append(l.Features, features...) + // set default features + setDefaultFeatures(lm) + + err := lm.InitFeatures(lm.activeFeatures) + if err != nil { + zap.L().Panic("Couldn't activate features", zap.Error(err)) + } + if !lm.validatorRunning { + // we want to make sure only one validator runs, + // we already have lock() so good to go + lm.validatorRunning = true + go lm.ValidatorV3(context.Background()) + } + } func setDefaultFeatures(lm *Manager) { @@ -137,6 +170,28 @@ func (lm *Manager) LoadActiveLicense(features ...basemodel.Feature) error { return nil } +func (lm *Manager) LoadActiveLicenseV3(features ...basemodel.Feature) error { + active, err := lm.repo.GetActiveLicenseV3(context.Background()) + if err != nil { + return err + } + if active != nil { + lm.SetActiveV3(active, features...) + } else { + zap.L().Info("No active license found, defaulting to basic plan") + // if no active license is found, we default to basic(free) plan with all default features + lm.activeFeatures = model.BasicPlan + setDefaultFeatures(lm) + err := lm.InitFeatures(lm.activeFeatures) + if err != nil { + zap.L().Error("Couldn't initialize features", zap.Error(err)) + return err + } + } + + return nil +} + func (lm *Manager) GetLicenses(ctx context.Context) (response []model.License, apiError *model.ApiError) { licenses, err := lm.repo.GetLicenses(ctx) @@ -163,6 +218,23 @@ func (lm *Manager) GetLicenses(ctx context.Context) (response []model.License, a return } +func (lm *Manager) GetLicensesV3(ctx context.Context) (response []*model.LicenseV3, apiError *model.ApiError) { + + licenses, err := lm.repo.GetLicensesV3(ctx) + if err != nil { + return nil, model.InternalError(err) + } + + for _, l := range licenses { + if lm.activeLicenseV3 != nil && l.Key == lm.activeLicenseV3.Key { + l.IsCurrent = true + } + response = append(response, l) + } + + return response, nil +} + // Validator validates license after an epoch of time func (lm *Manager) Validator(ctx context.Context) { defer close(lm.terminated) @@ -187,6 +259,30 @@ func (lm *Manager) Validator(ctx context.Context) { } } +// Validator validates license after an epoch of time +func (lm *Manager) ValidatorV3(ctx context.Context) { + defer close(lm.terminated) + tick := time.NewTicker(validationFrequency) + defer tick.Stop() + + lm.ValidateV3(ctx) + + for { + select { + case <-lm.done: + return + default: + select { + case <-lm.done: + return + case <-tick.C: + lm.ValidateV3(ctx) + } + } + + } +} + // Validate validates the current active license func (lm *Manager) Validate(ctx context.Context) (reterr error) { zap.L().Info("License validation started") @@ -254,6 +350,54 @@ func (lm *Manager) Validate(ctx context.Context) (reterr error) { return nil } +// todo[vikrantgupta25]: check the comparison here between old and new license! +func (lm *Manager) RefreshLicense(ctx context.Context) *model.ApiError { + + license, apiError := validate.ValidateLicenseV3(lm.activeLicenseV3.Key) + if apiError != nil { + zap.L().Error("failed to validate license", zap.Error(apiError.Err)) + return apiError + } + + err := lm.repo.UpdateLicenseV3(ctx, license) + if err != nil { + return model.BadRequest(errors.Wrap(err, "failed to update the new license")) + } + lm.SetActiveV3(license) + + return nil +} + +func (lm *Manager) ValidateV3(ctx context.Context) (reterr error) { + zap.L().Info("License validation started") + if lm.activeLicenseV3 == nil { + return nil + } + + defer func() { + lm.mutex.Lock() + + lm.lastValidated = time.Now().Unix() + if reterr != nil { + zap.L().Error("License validation completed with error", zap.Error(reterr)) + atomic.AddUint64(&lm.failedAttempts, 1) + telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_LICENSE_CHECK_FAILED, + map[string]interface{}{"err": reterr.Error()}, "", true, false) + } else { + zap.L().Info("License validation completed with no errors") + } + + lm.mutex.Unlock() + }() + + err := lm.RefreshLicense(ctx) + + if err != nil { + return err + } + return nil +} + // Activate activates a license key with signoz server func (lm *Manager) Activate(ctx context.Context, key string) (licenseResponse *model.License, errResponse *model.ApiError) { defer func() { @@ -298,6 +442,35 @@ func (lm *Manager) Activate(ctx context.Context, key string) (licenseResponse *m return l, nil } +func (lm *Manager) ActivateV3(ctx context.Context, licenseKey string) (licenseResponse *model.LicenseV3, errResponse *model.ApiError) { + defer func() { + if errResponse != nil { + userEmail, err := auth.GetEmailFromJwt(ctx) + if err == nil { + telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_LICENSE_ACT_FAILED, + map[string]interface{}{"err": errResponse.Err.Error()}, userEmail, true, false) + } + } + }() + + license, apiError := validate.ValidateLicenseV3(licenseKey) + if apiError != nil { + zap.L().Error("failed to get the license", zap.Error(apiError.Err)) + return nil, apiError + } + + // insert the new license to the sqlite db + err := lm.repo.InsertLicenseV3(ctx, license) + if err != nil { + zap.L().Error("failed to activate license", zap.Error(err)) + return nil, model.InternalError(err) + } + + // license is valid, activate it + lm.SetActiveV3(license) + return license, nil +} + // CheckFeature will be internally used by backend routines // for feature gating func (lm *Manager) CheckFeature(featureKey string) error { diff --git a/ee/query-service/license/sqlite/init.go b/ee/query-service/license/sqlite/init.go index c80bbd5a86..cd34081cc9 100644 --- a/ee/query-service/license/sqlite/init.go +++ b/ee/query-service/license/sqlite/init.go @@ -48,5 +48,16 @@ func InitDB(db *sqlx.DB) error { return fmt.Errorf("error in creating feature_status table: %s", err.Error()) } + table_schema = `CREATE TABLE IF NOT EXISTS licenses_v3 ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + data TEXT + );` + + _, err = db.Exec(table_schema) + if err != nil { + return fmt.Errorf("error in creating licenses_v3 table: %s", err.Error()) + } + return nil } diff --git a/ee/query-service/main.go b/ee/query-service/main.go index 41cc69aa49..55e70893e6 100644 --- a/ee/query-service/main.go +++ b/ee/query-service/main.go @@ -94,6 +94,7 @@ func main() { var cluster string var useLogsNewSchema bool + var useLicensesV3 bool var cacheConfigPath, fluxInterval string var enableQueryServiceLogOTLPExport bool var preferSpanMetrics bool @@ -104,6 +105,7 @@ func main() { var gatewayUrl string flag.BoolVar(&useLogsNewSchema, "use-logs-new-schema", false, "use logs_v2 schema for logs") + flag.BoolVar(&useLicensesV3, "use-licenses-v3", false, "use licenses_v3 schema for licenses") flag.StringVar(&promConfigPath, "config", "./config/prometheus.yml", "(prometheus config to read metrics)") flag.StringVar(&skipTopLvlOpsPath, "skip-top-level-ops", "", "(config file to skip top level operations)") flag.BoolVar(&disableRules, "rules.disable", false, "(disable rule evaluation)") @@ -143,6 +145,7 @@ func main() { Cluster: cluster, GatewayUrl: gatewayUrl, UseLogsNewSchema: useLogsNewSchema, + UseLicensesV3: useLicensesV3, } // Read the jwt secret key diff --git a/ee/query-service/model/errors.go b/ee/query-service/model/errors.go index 7e7b8410e2..efc780be95 100644 --- a/ee/query-service/model/errors.go +++ b/ee/query-service/model/errors.go @@ -46,6 +46,13 @@ func BadRequest(err error) *ApiError { } } +func Unauthorized(err error) *ApiError { + return &ApiError{ + Typ: basemodel.ErrorUnauthorized, + Err: err, + } +} + // BadRequestStr returns a ApiError object of bad request for string input func BadRequestStr(s string) *ApiError { return &ApiError{ diff --git a/ee/query-service/model/license.go b/ee/query-service/model/license.go index 7ad349c9b7..2f9a0feeda 100644 --- a/ee/query-service/model/license.go +++ b/ee/query-service/model/license.go @@ -3,6 +3,8 @@ package model import ( "encoding/base64" "encoding/json" + "fmt" + "reflect" "time" "github.com/pkg/errors" @@ -104,3 +106,144 @@ type SubscriptionServerResp struct { Status string `json:"status"` Data Licenses `json:"data"` } + +type Plan struct { + Name string `json:"name"` +} + +type LicenseDB struct { + ID string `json:"id"` + Key string `json:"key"` + Data string `json:"data"` +} +type LicenseV3 struct { + ID string + Key string + Data map[string]interface{} + PlanName string + Features basemodel.FeatureSet + Status string + IsCurrent bool + ValidFrom int64 + ValidUntil int64 +} + +func extractKeyFromMapStringInterface[T any](data map[string]interface{}, key string) (T, error) { + var zeroValue T + if val, ok := data[key]; ok { + if value, ok := val.(T); ok { + return value, nil + } + return zeroValue, fmt.Errorf("%s key is not a valid %s", key, reflect.TypeOf(zeroValue)) + } + return zeroValue, fmt.Errorf("%s key is missing", key) +} + +func NewLicenseV3(data map[string]interface{}) (*LicenseV3, error) { + var features basemodel.FeatureSet + + // extract id from data + licenseID, err := extractKeyFromMapStringInterface[string](data, "id") + if err != nil { + return nil, err + } + delete(data, "id") + + // extract key from data + licenseKey, err := extractKeyFromMapStringInterface[string](data, "key") + if err != nil { + return nil, err + } + delete(data, "key") + + // extract status from data + status, err := extractKeyFromMapStringInterface[string](data, "status") + if err != nil { + return nil, err + } + + planMap, err := extractKeyFromMapStringInterface[map[string]any](data, "plan") + if err != nil { + return nil, err + } + + planName, err := extractKeyFromMapStringInterface[string](planMap, "name") + if err != nil { + return nil, err + } + // if license status is inactive then default it to basic + if status == LicenseStatusInactive { + planName = PlanNameBasic + } + + featuresFromZeus := basemodel.FeatureSet{} + if _features, ok := data["features"]; ok { + featuresData, err := json.Marshal(_features) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal features data") + } + + if err := json.Unmarshal(featuresData, &featuresFromZeus); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal features data") + } + } + + switch planName { + case PlanNameTeams: + features = append(features, ProPlan...) + case PlanNameEnterprise: + features = append(features, EnterprisePlan...) + case PlanNameBasic: + features = append(features, BasicPlan...) + default: + features = append(features, BasicPlan...) + } + + if len(featuresFromZeus) > 0 { + for _, feature := range featuresFromZeus { + exists := false + for i, existingFeature := range features { + if existingFeature.Name == feature.Name { + features[i] = feature // Replace existing feature + exists = true + break + } + } + if !exists { + features = append(features, feature) // Append if it doesn't exist + } + } + } + data["features"] = features + + _validFrom, err := extractKeyFromMapStringInterface[float64](data, "valid_from") + if err != nil { + _validFrom = 0 + } + validFrom := int64(_validFrom) + + _validUntil, err := extractKeyFromMapStringInterface[float64](data, "valid_until") + if err != nil { + _validUntil = 0 + } + validUntil := int64(_validUntil) + + return &LicenseV3{ + ID: licenseID, + Key: licenseKey, + Data: data, + PlanName: planName, + Features: features, + ValidFrom: validFrom, + ValidUntil: validUntil, + Status: status, + }, nil + +} + +func NewLicenseV3WithIDAndKey(id string, key string, data map[string]interface{}) (*LicenseV3, error) { + licenseDataWithIdAndKey := data + licenseDataWithIdAndKey["id"] = id + licenseDataWithIdAndKey["key"] = key + return NewLicenseV3(licenseDataWithIdAndKey) +} diff --git a/ee/query-service/model/license_test.go b/ee/query-service/model/license_test.go new file mode 100644 index 0000000000..1c6150c8ac --- /dev/null +++ b/ee/query-service/model/license_test.go @@ -0,0 +1,170 @@ +package model + +import ( + "encoding/json" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.signoz.io/signoz/pkg/query-service/model" +) + +func TestNewLicenseV3(t *testing.T) { + testCases := []struct { + name string + data []byte + pass bool + expected *LicenseV3 + error error + }{ + { + name: "Error for missing license id", + data: []byte(`{}`), + pass: false, + error: errors.New("id key is missing"), + }, + { + name: "Error for license id not being a valid string", + data: []byte(`{"id": 10}`), + pass: false, + error: errors.New("id key is not a valid string"), + }, + { + name: "Error for missing license key", + data: []byte(`{"id":"does-not-matter"}`), + pass: false, + error: errors.New("key key is missing"), + }, + { + name: "Error for invalid string license key", + data: []byte(`{"id":"does-not-matter","key":10}`), + pass: false, + error: errors.New("key key is not a valid string"), + }, + { + name: "Error for missing license status", + data: []byte(`{"id":"does-not-matter", "key": "does-not-matter","category":"FREE"}`), + pass: false, + error: errors.New("status key is missing"), + }, + { + name: "Error for invalid string license status", + data: []byte(`{"id":"does-not-matter","key": "does-not-matter", "category":"FREE", "status":10}`), + pass: false, + error: errors.New("status key is not a valid string"), + }, + { + name: "Error for missing license plan", + data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE"}`), + pass: false, + error: errors.New("plan key is missing"), + }, + { + name: "Error for invalid json license plan", + data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":10}`), + pass: false, + error: errors.New("plan key is not a valid map[string]interface {}"), + }, + { + name: "Error for invalid license plan", + data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{}}`), + pass: false, + error: errors.New("name key is missing"), + }, + { + name: "Parse the entire license properly", + data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"TEAMS"},"valid_from": 1730899309,"valid_until": -1}`), + pass: true, + expected: &LicenseV3{ + ID: "does-not-matter", + Key: "does-not-matter-key", + Data: map[string]interface{}{ + "plan": map[string]interface{}{ + "name": "TEAMS", + }, + "category": "FREE", + "status": "ACTIVE", + "valid_from": float64(1730899309), + "valid_until": float64(-1), + }, + PlanName: PlanNameTeams, + ValidFrom: 1730899309, + ValidUntil: -1, + Status: "ACTIVE", + IsCurrent: false, + Features: model.FeatureSet{}, + }, + }, + { + name: "Fallback to basic plan if license status is inactive", + data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"INACTIVE","plan":{"name":"TEAMS"},"valid_from": 1730899309,"valid_until": -1}`), + pass: true, + expected: &LicenseV3{ + ID: "does-not-matter", + Key: "does-not-matter-key", + Data: map[string]interface{}{ + "plan": map[string]interface{}{ + "name": "TEAMS", + }, + "category": "FREE", + "status": "INACTIVE", + "valid_from": float64(1730899309), + "valid_until": float64(-1), + }, + PlanName: PlanNameBasic, + ValidFrom: 1730899309, + ValidUntil: -1, + Status: "INACTIVE", + IsCurrent: false, + Features: model.FeatureSet{}, + }, + }, + { + name: "fallback states for validFrom and validUntil", + data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"TEAMS"},"valid_from":1234.456,"valid_until":5678.567}`), + pass: true, + expected: &LicenseV3{ + ID: "does-not-matter", + Key: "does-not-matter-key", + Data: map[string]interface{}{ + "plan": map[string]interface{}{ + "name": "TEAMS", + }, + "valid_from": 1234.456, + "valid_until": 5678.567, + "category": "FREE", + "status": "ACTIVE", + }, + PlanName: PlanNameTeams, + ValidFrom: 1234, + ValidUntil: 5678, + Status: "ACTIVE", + IsCurrent: false, + Features: model.FeatureSet{}, + }, + }, + } + + for _, tc := range testCases { + var licensePayload map[string]interface{} + err := json.Unmarshal(tc.data, &licensePayload) + require.NoError(t, err) + license, err := NewLicenseV3(licensePayload) + if license != nil { + license.Features = make(model.FeatureSet, 0) + delete(license.Data, "features") + } + + if tc.pass { + require.NoError(t, err) + require.NotNil(t, license) + assert.Equal(t, tc.expected, license) + } else { + require.Error(t, err) + assert.EqualError(t, err, tc.error.Error()) + require.Nil(t, license) + } + + } +} diff --git a/ee/query-service/model/plans.go b/ee/query-service/model/plans.go index c5272340a3..1ac9ac28d6 100644 --- a/ee/query-service/model/plans.go +++ b/ee/query-service/model/plans.go @@ -9,6 +9,17 @@ const SSO = "SSO" const Basic = "BASIC_PLAN" const Pro = "PRO_PLAN" const Enterprise = "ENTERPRISE_PLAN" + +var ( + PlanNameEnterprise = "ENTERPRISE" + PlanNameTeams = "TEAMS" + PlanNameBasic = "BASIC" +) + +var ( + LicenseStatusInactive = "INACTIVE" +) + const DisableUpsell = "DISABLE_UPSELL" const Onboarding = "ONBOARDING" const ChatSupport = "CHAT_SUPPORT" diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 968aca0030..c488595584 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -111,6 +111,7 @@ type APIHandler struct { Upgrader *websocket.Upgrader UseLogsNewSchema bool + UseLicensesV3 bool hostsRepo *inframetrics.HostsRepo processesRepo *inframetrics.ProcessesRepo @@ -156,6 +157,9 @@ type APIHandlerOpts struct { // Use Logs New schema UseLogsNewSchema bool + + // Use Licenses V3 structure + UseLicensesV3 bool } // NewAPIHandler returns an APIHandler @@ -211,6 +215,7 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) { querier: querier, querierV2: querierv2, UseLogsNewSchema: opts.UseLogsNewSchema, + UseLicensesV3: opts.UseLicensesV3, hostsRepo: hostsRepo, processesRepo: processesRepo, podsRepo: podsRepo,