diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index 6c9e0148cb45a9..3285d62b77d228 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -13,13 +13,14 @@ import { UserContext } from "./user-context"; import { TeamsContext } from "./teams/teams-context"; import { ThemeContext } from "./theme-context"; import { AdminContext } from "./admin-context"; +import { LicenseContext } from "./license-context"; import { getGitpodService } from "./service/service"; import { shouldSeeWhatsNew, WhatsNew } from "./whatsnew/WhatsNew"; import gitpodIcon from "./icons/gitpod.svg"; import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { useHistory } from "react-router-dom"; import { trackButtonOrAnchor, trackPathChange, trackLocation } from "./Analytics"; -import { User } from "@gitpod/gitpod-protocol"; +import { LicenseInfo, User } from "@gitpod/gitpod-protocol"; import * as GitpodCookie from "@gitpod/gitpod-protocol/lib/util/gitpod-cookie"; import { Experiment } from "./experiments"; import { workspacesPathMain } from "./workspaces/workspaces.routes"; @@ -77,6 +78,7 @@ const AdminSettings = React.lazy(() => import(/* webpackPrefetch: true */ "./adm const ProjectsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/ProjectsSearch")); const TeamsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/TeamsSearch")); const OAuthClientApproval = React.lazy(() => import(/* webpackPrefetch: true */ "./OauthClientApproval")); +const License = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/License")); function Loading() { return <>; @@ -150,6 +152,7 @@ function App() { const { teams, setTeams } = useContext(TeamsContext); const { setAdminSettings } = useContext(AdminContext); const { setIsDark } = useContext(ThemeContext); + const { setLicense } = useContext(LicenseContext); const [loading, setLoading] = useState(true); const [isWhatsNewShown, setWhatsNewShown] = useState(false); @@ -186,6 +189,9 @@ function App() { if (user?.rolesOrPermissions?.includes("admin")) { const adminSettings = await getGitpodService().server.adminGetSettings(); setAdminSettings(adminSettings); + + var license: LicenseInfo = await getGitpodService().server.adminGetLicense(); + setLicense(license); } } catch (error) { console.error(error); @@ -367,6 +373,7 @@ function App() { + diff --git a/components/dashboard/src/admin/License.tsx b/components/dashboard/src/admin/License.tsx new file mode 100644 index 00000000000000..87c67daf3a5a8a --- /dev/null +++ b/components/dashboard/src/admin/License.tsx @@ -0,0 +1,177 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { PageWithSubMenu } from "../components/PageWithSubMenu"; +import { adminMenu } from "./admin-menu"; + +import { LicenseContext } from "../license-context"; +import { ReactElement, useContext, useEffect } from "react"; +import { getGitpodService } from "../service/service"; + +import { ReactComponent as Alert } from "../images/exclamation.svg"; +import { ReactComponent as Success } from "../images/tick.svg"; +import { ReactComponent as Tick } from "../images/check.svg"; +import { ReactComponent as Cross } from "../images/x.svg"; + +export default function License() { + const { license, setLicense } = useContext(LicenseContext); + + useEffect(() => { + if (isGitpodIo()) { + return; // temporarily disable to avoid hight CPU on the DB + } + (async () => { + const data = await getGitpodService().server.adminGetLicense(); + setLicense(data); + })(); + }, []); + + const featureList = license?.enabledFeatures; + const features = license?.features; + + // if user seats is 0, it means that there is user limit in the license + const userLimit = license?.seats == 0 ? "Unlimited" : license?.seats; + + const [licenseLevel, paid, msg, tick] = getSubscriptionLevel( + license?.plan || "", + license?.userCount || 0, + license?.seats || 0, + license?.fallbackAllowed || false, + ); + + return ( +
+ +
+ +

{licenseLevel}

+

{paid}

+

Available features:

+ {features && + features.map((feat: string) => ( +
+ {featureList?.includes(feat) ? ( + + ) : ( + + )} + {capitalizeInitials(feat)} +
+ ))} +
+ +
+
{msg}
+
{getLicenseValidIcon(tick)}
+
+

Registered Users

+ {license?.userCount || 0} + / {userLimit} +

License Type

+

{capitalizeInitials(license?.type || "")}

+ +
+
+
+
+ ); +} + +function capitalizeInitials(str: string): string { + return str + .split("-") + .map((item) => { + return item.charAt(0).toUpperCase() + item.slice(1); + }) + .join(" "); +} + +function getSubscriptionLevel(level: string, userCount: number, seats: number, fallbackAllowed: boolean): string[] { + switch (level) { + case "prod": { + return professionalPlan(userCount, seats); + } + case "community": { + return communityPlan(userCount, seats, fallbackAllowed); + } + case "trial": { + return ["Trial", "Free", "You have a trial license.", "grey-tick"]; + } + default: { + return ["Unknown", "Free", "No active licenses.", "red-cross"]; + } + } +} + +function professionalPlan(userCount: number, seats: number): string[] { + const aboveLimit: boolean = userCount > seats; + let msg: string, tick: string; + if (aboveLimit) { + msg = "You have exceeded the usage limit."; + tick = "red-cross"; + } else { + msg = "You have an active professional license."; + tick = "green-tick"; + } + + return ["Professional", "Paid", msg, tick]; +} + +function communityPlan(userCount: number, seats: number, fallbackAllowed: boolean): string[] { + const aboveLimit: boolean = userCount > seats; + + let msg: string = "You are using the free community edition"; + let tick: string = "green-tick"; + if (aboveLimit) { + if (fallbackAllowed) { + msg = "No active license. You are using the community edition."; + tick = "grey-tick"; + } else { + msg = "No active license. You have exceeded the usage limit."; + tick = "red-cross"; + } + } + + return ["Community", "Free", msg, tick]; +} + +function getLicenseValidIcon(iconname: string): ReactElement { + switch (iconname) { + case "green-tick": + return ; + case "grey-tick": + return ; + case "red-cross": + return ; + default: + return ; + } +} + +function isGitpodIo() { + return window.location.hostname === "gitpod.io" || window.location.hostname === "gitpod-staging.com"; +} + +function Card(p: { className?: string; children?: React.ReactNode }) { + return ( +
+ {p.children} +
+ ); +} diff --git a/components/dashboard/src/admin/admin-menu.ts b/components/dashboard/src/admin/admin-menu.ts index 91e5a88a93a78a..a62001a9fafb60 100644 --- a/components/dashboard/src/admin/admin-menu.ts +++ b/components/dashboard/src/admin/admin-menu.ts @@ -21,6 +21,10 @@ export const adminMenu = [ title: "Teams", link: ["/admin/teams"], }, + { + title: "License", + link: ["/admin/license"], + }, { title: "Settings", link: ["/admin/settings"], diff --git a/components/dashboard/src/images/check.svg b/components/dashboard/src/images/check.svg new file mode 100644 index 00000000000000..5e150e9c299e21 --- /dev/null +++ b/components/dashboard/src/images/check.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/components/dashboard/src/images/tick.svg b/components/dashboard/src/images/tick.svg new file mode 100644 index 00000000000000..f2f05ec54aa09d --- /dev/null +++ b/components/dashboard/src/images/tick.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/components/dashboard/src/index.tsx b/components/dashboard/src/index.tsx index 13a095b62a7865..10cf8ea23ab8eb 100644 --- a/components/dashboard/src/index.tsx +++ b/components/dashboard/src/index.tsx @@ -10,6 +10,7 @@ import App from "./App"; import { UserContextProvider } from "./user-context"; import { AdminContextProvider } from "./admin-context"; import { PaymentContextProvider } from "./payment-context"; +import { LicenseContextProvider } from "./license-context"; import { TeamsContextProvider } from "./teams/teams-context"; import { ProjectContextProvider } from "./projects/project-context"; import { ThemeContextProvider } from "./theme-context"; @@ -23,17 +24,19 @@ ReactDOM.render( - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/components/dashboard/src/license-context.tsx b/components/dashboard/src/license-context.tsx new file mode 100644 index 00000000000000..789ef69b020089 --- /dev/null +++ b/components/dashboard/src/license-context.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import React, { createContext, useState } from "react"; +import { LicenseInfo } from "@gitpod/gitpod-protocol"; + +const LicenseContext = createContext<{ + license?: LicenseInfo; + setLicense: React.Dispatch; +}>({ + setLicense: () => null, +}); + +const LicenseContextProvider: React.FC = ({ children }) => { + const [license, setLicense] = useState(); + return {children}; +}; + +export { LicenseContext, LicenseContextProvider }; diff --git a/components/gitpod-protocol/src/license-protocol.ts b/components/gitpod-protocol/src/license-protocol.ts index ce0042e655a1c3..6447ac95225282 100644 --- a/components/gitpod-protocol/src/license-protocol.ts +++ b/components/gitpod-protocol/src/license-protocol.ts @@ -15,9 +15,15 @@ export type LicenseIssue = "seats-exhausted"; export interface LicenseInfo { key: string; seats: number; + userCount?: number; valid: boolean; validUntil: string; plan?: string; + features?: string[]; + enabledFeatures?: string[]; + type?: string; + errorMsg?: string; + fallbackAllowed: boolean; } export interface GetLicenseInfoResult { @@ -33,5 +39,6 @@ export enum LicenseFeature { export interface LicenseService { validateLicense(): Promise; getLicenseInfo(): Promise; + adminGetLicense(): Promise; licenseIncludesFeature(feature: LicenseFeature): Promise; } diff --git a/components/licensor/ee/pkg/licensor/gitpod.go b/components/licensor/ee/pkg/licensor/gitpod.go index 05592b787d5de8..c474b69c1849fa 100644 --- a/components/licensor/ee/pkg/licensor/gitpod.go +++ b/components/licensor/ee/pkg/licensor/gitpod.go @@ -21,6 +21,7 @@ func NewGitpodEvaluator(key []byte, domain string) (res *Evaluator) { return &Evaluator{ lic: defaultLicense, allowFallback: true, + plan: CommunityLicense, } } @@ -63,6 +64,7 @@ func NewGitpodEvaluator(key []byte, domain string) (res *Evaluator) { return &Evaluator{ lic: lic.LicensePayload, - allowFallback: false, // Gitpod licenses cannot fallback - assume these are always paid-for + allowFallback: false, // Gitpod licenses cannot fallback - assume these are always paid-for + plan: ProfessionalLicense, // This essentially means "paid" license } } diff --git a/components/licensor/ee/pkg/licensor/licensor.go b/components/licensor/ee/pkg/licensor/licensor.go index 0058fbd96f9e96..562f2279936c1b 100644 --- a/components/licensor/ee/pkg/licensor/licensor.go +++ b/components/licensor/ee/pkg/licensor/licensor.go @@ -24,12 +24,30 @@ const ( LicenseTypeReplicated LicenseType = "replicated" ) +// LicenseSubscriptionLevel is initialized to have a standard license plan +// between replicated and gitpod licenses +type LicenseSubscriptionLevel string + +const ( + CommunityLicense LicenseSubscriptionLevel = "community" + ProfessionalLicense LicenseSubscriptionLevel = "prod" +) + +// LicenseData has type specific info about the license +type LicenseData struct { + Type LicenseType `json:"type"` + Payload LicensePayload `json:"payload"` + Plan LicenseSubscriptionLevel `json:"plan"` + FallbackAllowed bool `json:"fallbackAllowed"` +} + // LicensePayload is the actual license content type LicensePayload struct { ID string `json:"id"` Domain string `json:"domain"` Level LicenseLevel `json:"level"` ValidUntil time.Time `json:"validUntil"` + // Type LicenseType `json:"type"` // Seats == 0 means there's no seat limit Seats int `json:"seats"` @@ -153,6 +171,7 @@ type Evaluator struct { invalid string allowFallback bool // Paid licenses cannot fallback and prevent additional signups lic LicensePayload + plan LicenseSubscriptionLevel // Specifies if it is a community/free plan or paid plan } // Validate returns false if the license isn't valid and a message explaining why that is. @@ -212,6 +231,21 @@ func (e *Evaluator) Inspect() LicensePayload { return e.lic } +func (e *Evaluator) LicenseData() LicenseData { + data := LicenseData{ + Type: LicenseType(e.GetLicenseType()), + Payload: e.Inspect(), + FallbackAllowed: e.allowFallback, + Plan: e.plan, + } + + return data +} + +func (e *Evaluator) GetLicenseType() string { + return os.Getenv("GITPOD_LICENSE_TYPE") +} + // Sign signs a license so that it can be used with the evaluator func Sign(l LicensePayload, priv *rsa.PrivateKey) (res []byte, err error) { rawl, err := json.Marshal(l) diff --git a/components/licensor/ee/pkg/licensor/licensor_test.go b/components/licensor/ee/pkg/licensor/licensor_test.go index 9e730c1d625e27..5f07df8ae3caa3 100644 --- a/components/licensor/ee/pkg/licensor/licensor_test.go +++ b/components/licensor/ee/pkg/licensor/licensor_test.go @@ -28,7 +28,7 @@ type licenseTest struct { Validate func(t *testing.T, eval *Evaluator) Type LicenseType NeverExpires bool - ReplicatedLicenseType *ReplicatedLicenseType + ReplicatedLicenseType *LicenseSubscriptionLevel } // roundTripFunc . @@ -77,7 +77,7 @@ func (test *licenseTest) Run(t *testing.T) { } payload, err := json.Marshal(replicatedLicensePayload{ - LicenseType: func() ReplicatedLicenseType { + LicenseType: func() LicenseSubscriptionLevel { if test.ReplicatedLicenseType == nil { return ReplicatedLicenseTypePaid } @@ -220,7 +220,7 @@ func TestFeatures(t *testing.T) { Features []Feature LicenseType LicenseType UserCount int - ReplicatedLicenseType *ReplicatedLicenseType + ReplicatedLicenseType *LicenseSubscriptionLevel }{ {"Gitpod (in seats): no license", true, LicenseLevel(0), []Feature{ FeatureAdminDashboard, diff --git a/components/licensor/ee/pkg/licensor/replicated.go b/components/licensor/ee/pkg/licensor/replicated.go index 3d54b7b4c1bf60..9d9d3bc5d482b2 100644 --- a/components/licensor/ee/pkg/licensor/replicated.go +++ b/components/licensor/ee/pkg/licensor/replicated.go @@ -23,30 +23,30 @@ type replicatedFields struct { Value interface{} `json:"value"` // This is of type "fieldType" } -type ReplicatedLicenseType string - // variable names are what Replicated calls them in the vendor portal const ( - ReplicatedLicenseTypeCommunity ReplicatedLicenseType = "community" - ReplicatedLicenseTypeDevelopment ReplicatedLicenseType = "dev" - ReplicatedLicenseTypePaid ReplicatedLicenseType = "prod" - ReplicatedLicenseTypeTrial ReplicatedLicenseType = "trial" + ReplicatedLicenseTypeCommunity LicenseSubscriptionLevel = "community" + ReplicatedLicenseTypeDevelopment LicenseSubscriptionLevel = "dev" + ReplicatedLicenseTypePaid LicenseSubscriptionLevel = "prod" + ReplicatedLicenseTypeTrial LicenseSubscriptionLevel = "trial" ) // replicatedLicensePayload exists to convert the JSON structure to a LicensePayload type replicatedLicensePayload struct { - LicenseID string `json:"license_id"` - InstallationID string `json:"installation_id"` - Assignee string `json:"assignee"` - ReleaseChannel string `json:"release_channel"` - LicenseType ReplicatedLicenseType `json:"license_type"` - ExpirationTime *time.Time `json:"expiration_time,omitempty"` // Not set if license never expires - Fields []replicatedFields `json:"fields"` + LicenseID string `json:"license_id"` + InstallationID string `json:"installation_id"` + Assignee string `json:"assignee"` + ReleaseChannel string `json:"release_channel"` + LicenseType LicenseSubscriptionLevel `json:"license_type"` + ExpirationTime *time.Time `json:"expiration_time,omitempty"` // Not set if license never expires + Fields []replicatedFields `json:"fields"` } type ReplicatedEvaluator struct { - invalid string - lic LicensePayload + invalid string + lic LicensePayload + plan LicenseSubscriptionLevel + allowFallback bool } func (e *ReplicatedEvaluator) Enabled(feature Feature) bool { @@ -66,6 +66,17 @@ func (e *ReplicatedEvaluator) HasEnoughSeats(seats int) bool { return e.lic.Seats == 0 || seats <= e.lic.Seats } +func (e *ReplicatedEvaluator) LicenseData() LicenseData { + data := LicenseData{ + Type: LicenseTypeReplicated, + Payload: e.Inspect(), + FallbackAllowed: e.allowFallback, + Plan: e.plan, + } + + return data +} + func (e *ReplicatedEvaluator) Inspect() LicensePayload { return e.lic } @@ -80,9 +91,11 @@ func (e *ReplicatedEvaluator) Validate() (msg string, valid bool) { // defaultReplicatedLicense this is the default license if call fails func defaultReplicatedLicense() *Evaluator { + return &Evaluator{ lic: defaultLicense, allowFallback: true, + plan: ReplicatedLicenseTypeCommunity, } } @@ -131,6 +144,7 @@ func newReplicatedEvaluator(client *http.Client, domain string) (res *Evaluator) return &Evaluator{ lic: lic, allowFallback: replicatedPayload.LicenseType == ReplicatedLicenseTypeCommunity, // Only community licenses are allowed to fallback + plan: replicatedPayload.LicenseType, } } diff --git a/components/licensor/typescript/ee/genapi.go b/components/licensor/typescript/ee/genapi.go index fd172463048201..af6d2b8576ca67 100644 --- a/components/licensor/typescript/ee/genapi.go +++ b/components/licensor/typescript/ee/genapi.go @@ -74,6 +74,26 @@ func main() { res = append(res, t) } + ts, err = bel.Extract(licensor.LicenseData{}, bel.WithEnumerations(handler)) + if err != nil { + panic(err) + } + for _, t := range ts { + if t.Name == "" { + continue + } + + if t.Name == "LicenseData" { + for i, m := range t.Members { + if m.Name == "payload" { + t.Members[i].Type.Name = "LicensePayload" + } + } + } + + res = append(res, t) + } + sort.Slice(res, func(i, j int) bool { return res[i].Name < res[j].Name }) f, err := os.Create("src/api.ts") diff --git a/components/licensor/typescript/ee/main.go b/components/licensor/typescript/ee/main.go index a5f81a117fc8ad..cc000085d24b18 100644 --- a/components/licensor/typescript/ee/main.go +++ b/components/licensor/typescript/ee/main.go @@ -35,6 +35,24 @@ func Init(key *C.char, domain *C.char) (id int) { return id } +// GetLicenseData returns the info about license for the admin dashboard +//export GetLicenseData +func GetLicenseData(id int) (licData *C.char, ok bool) { + e, ok := instances[id] + if !ok { + return + } + + b, err := json.Marshal(e.LicenseData()) + if err != nil { + log.WithError(err).Warn("GetLicenseData(): cannot retrieve license data") + return nil, false + } + + return C.CString(string(b)), true + +} + // Validate returns false if the license isn't valid and a message explaining why that is. //export Validate func Validate(id int) (msg *C.char, valid bool) { diff --git a/components/licensor/typescript/ee/src/api.ts b/components/licensor/typescript/ee/src/api.ts index 00937b5c63823a..c7f1a9c634a76b 100644 --- a/components/licensor/typescript/ee/src/api.ts +++ b/components/licensor/typescript/ee/src/api.ts @@ -12,6 +12,13 @@ export enum Feature { FeatureSnapshot = "snapshot", FeatureWorkspaceSharing = "workspace-sharing", } +export interface LicenseData { + type: LicenseType + payload: LicensePayload + plan: string + fallbackAllowed: boolean +} + export enum LicenseLevel { LevelTeam = 0, LevelEnterprise = 1, @@ -23,3 +30,8 @@ export interface LicensePayload { validUntil: string seats: number } + +export enum LicenseType { + LicenseTypeGitpod = "gitpod", + LicenseTypeReplicated = "replicated", +} diff --git a/components/licensor/typescript/ee/src/index.ts b/components/licensor/typescript/ee/src/index.ts index d4b24bc083bb79..b9495ff9d714fa 100644 --- a/components/licensor/typescript/ee/src/index.ts +++ b/components/licensor/typescript/ee/src/index.ts @@ -5,8 +5,8 @@ */ import { injectable, inject, postConstruct } from 'inversify'; -import { init, Instance, dispose, isEnabled, hasEnoughSeats, inspect, validate } from "./nativemodule"; -import { Feature, LicensePayload } from './api'; +import { init, Instance, dispose, isEnabled, hasEnoughSeats, inspect, validate, getLicenseData } from "./nativemodule"; +import { Feature, LicensePayload, LicenseData } from './api'; export const LicenseKeySource = Symbol("LicenseKeySource"); @@ -61,6 +61,10 @@ export class LicenseEvaluator { return JSON.parse(inspect(this.instanceID)); } + public getLicenseData(): LicenseData { + return JSON.parse(getLicenseData(this.instanceID)); + } + public dispose() { dispose(this.instanceID); } diff --git a/components/licensor/typescript/ee/src/module.cc b/components/licensor/typescript/ee/src/module.cc index 3787bcfc5bbbde..3595c147cc5736 100644 --- a/components/licensor/typescript/ee/src/module.cc +++ b/components/licensor/typescript/ee/src/module.cc @@ -176,6 +176,31 @@ void HasEnoughSeatsM(const FunctionCallbackInfo &args) { args.GetReturnValue().Set(Boolean::New(isolate, r.r0)); } +void GetLicenseDataM(const FunctionCallbackInfo &args) { + Isolate *isolate = args.GetIsolate(); + Local context = isolate->GetCurrentContext(); + + if (args.Length() < 1) { + isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "wrong number of arguments").ToLocalChecked())); + return; + } + if (!args[0]->IsNumber() || args[0]->IsUndefined()) { + isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "argument 0 must be a number").ToLocalChecked())); + return; + } + + double rid = args[0]->NumberValue(context).FromMaybe(0); + int id = static_cast(rid); + + GetLicenseData_return r = GetLicenseData(id); + if (!r.r1) { + isolate->ThrowException(Exception::Error(String::NewFromUtf8(isolate, "invalid instance ID").ToLocalChecked())); + return; + } + + args.GetReturnValue().Set(String::NewFromUtf8(isolate, r.r0).ToLocalChecked()); +} + void InspectM(const FunctionCallbackInfo &args) { Isolate *isolate = args.GetIsolate(); Local context = isolate->GetCurrentContext(); @@ -228,6 +253,7 @@ void initModule(Local exports) { NODE_SET_METHOD(exports, "hasEnoughSeats", HasEnoughSeatsM); NODE_SET_METHOD(exports, "inspect", InspectM); NODE_SET_METHOD(exports, "dispose", DisposeM); + NODE_SET_METHOD(exports, "getLicenseData", GetLicenseDataM); } // create module diff --git a/components/licensor/typescript/ee/src/nativemodule.d.ts b/components/licensor/typescript/ee/src/nativemodule.d.ts index ae90944cbcbffe..d74dae36e86c6f 100644 --- a/components/licensor/typescript/ee/src/nativemodule.d.ts +++ b/components/licensor/typescript/ee/src/nativemodule.d.ts @@ -13,3 +13,4 @@ export function isEnabled(id: Instance, feature: Feature, seats: int): boolean; export function hasEnoughSeats(id: Instance, seats: int): boolean; export function inspect(id: Instance): string; export function dispose(id: Instance); +export function getLicenseData(id: Instance): string; diff --git a/components/server/ee/src/container-module.ts b/components/server/ee/src/container-module.ts index 43c1a884c71ae3..23d8b144df7268 100644 --- a/components/server/ee/src/container-module.ts +++ b/components/server/ee/src/container-module.ts @@ -11,7 +11,7 @@ import { Server } from "../../src/server"; import { ServerEE } from "./server"; import { UserController } from "../../src/user/user-controller"; import { UserControllerEE } from "./user/user-controller"; -import { LicenseEvaluator, LicenseKeySource } from "@gitpod/licensor/lib"; +import { LicenseKeySource } from "@gitpod/licensor/lib"; import { DBLicenseKeySource } from "./license-source"; import { UserService } from "../../src/user/user-service"; import { UserServiceEE } from "./user/user-service"; @@ -82,7 +82,6 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is bind(UserCounter).toSelf().inSingletonScope(); - bind(LicenseEvaluator).toSelf().inSingletonScope(); bind(LicenseKeySource).to(DBLicenseKeySource).inSingletonScope(); // GitpodServerImpl (stateful per user) diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 6d46644431073b..7d53fbe4dedc1e 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -59,7 +59,7 @@ import { import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { v4 as uuidv4 } from "uuid"; import { log, LogContext } from "@gitpod/gitpod-protocol/lib/util/logging"; -import { LicenseEvaluator, LicenseKeySource } from "@gitpod/licensor/lib"; +import { LicenseKeySource } from "@gitpod/licensor/lib"; import { Feature } from "@gitpod/licensor/lib/api"; import { LicenseValidationResult, LicenseFeature } from "@gitpod/gitpod-protocol/lib/license-protocol"; import { PrebuildManager } from "../prebuilds/prebuild-manager"; @@ -102,7 +102,6 @@ import { UserCounter } from "../user/user-counter"; @injectable() export class GitpodServerEEImpl extends GitpodServerImpl { - @inject(LicenseEvaluator) protected readonly licenseEvaluator: LicenseEvaluator; @inject(PrebuildManager) protected readonly prebuildManager: PrebuildManager; @inject(LicenseDB) protected readonly licenseDB: LicenseDB; @inject(LicenseKeySource) protected readonly licenseKeySource: LicenseKeySource; diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index a8f8d6da017d34..7e13d8e64a6e9b 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -146,6 +146,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { adminGetProjectsBySearchTerm: { group: "default", points: 1 }, adminGetProjectById: { group: "default", points: 1 }, adminFindPrebuilds: { group: "default", points: 1 }, + adminGetLicense: { group: "default", points: 1 }, adminSetLicense: { group: "default", points: 1 }, adminGetSettings: { group: "default", points: 1 }, adminUpdateSettings: { group: "default", points: 1 }, diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index e4ac066622fe52..dc6797e3f50eb9 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -97,6 +97,7 @@ import { LocalMessageBroker, LocalRabbitMQBackedMessageBroker } from "./messagin import { contentServiceBinder } from "@gitpod/content-service/lib/sugar"; import { ReferrerPrefixParser } from "./workspace/referrer-prefix-context-parser"; import { InstallationAdminTelemetryDataProvider } from "./installation-admin/telemetry-data-provider"; +import { LicenseEvaluator } from "@gitpod/licensor/lib"; export const productionContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { bind(Config).toConstantValue(ConfigFile.fromFile()); @@ -213,6 +214,8 @@ export const productionContainerModule = new ContainerModule((bind, unbind, isBo bind(InstallationAdminTelemetryDataProvider).toSelf().inSingletonScope(); + bind(LicenseEvaluator).toSelf().inSingletonScope(); + // binds all content services contentServiceBinder((ctx) => { const config = ctx.container.get(Config); diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 17ca4dc765b7da..ea7ae83174885a 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -89,6 +89,7 @@ import { import { GetLicenseInfoResult, LicenseFeature, + LicenseInfo, LicenseValidationResult, } from "@gitpod/gitpod-protocol/lib/license-protocol"; import { GitpodFileParser } from "@gitpod/gitpod-protocol/lib/gitpod-file-parser"; @@ -157,6 +158,8 @@ import { ProjectEnvVar } from "@gitpod/gitpod-protocol/src/protocol"; import { InstallationAdminSettings, TelemetryData } from "@gitpod/gitpod-protocol"; import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred"; import { InstallationAdminTelemetryDataProvider } from "../installation-admin/telemetry-data-provider"; +import { LicenseEvaluator } from "@gitpod/licensor/lib"; +import { Feature } from "@gitpod/licensor/lib/api"; // shortcut export const traceWI = (ctx: TraceContext, wi: Omit) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager @@ -183,6 +186,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { @inject(InstallationAdminDB) protected readonly installationAdminDb: InstallationAdminDB; @inject(InstallationAdminTelemetryDataProvider) protected readonly telemetryDataProvider: InstallationAdminTelemetryDataProvider; + @inject(LicenseEvaluator) protected readonly licenseEvaluator: LicenseEvaluator; @inject(WorkspaceStarter) protected readonly workspaceStarter: WorkspaceStarter; @inject(WorkspaceManagerClientProvider) @@ -2600,6 +2604,43 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { throw new ResponseError(ErrorCodes.EE_FEATURE, `Licensing is implemented in Gitpod's Enterprise Edition`); } + async adminGetLicense(ctx: TraceContext): Promise { + const licenseData = this.licenseEvaluator.getLicenseData(); + const licensePayload = licenseData.payload; + const licenseValid = this.licenseEvaluator.validate(); + + const userCount = await this.userDB.getUserCount(true); + + const features = Object.keys(Feature); + const enabledFeatures = await this.licenseFeatures(ctx, features, userCount); + + return { + key: licensePayload.id, + seats: licensePayload.seats, + userCount: userCount, + plan: licenseData.plan, + fallbackAllowed: licenseData.fallbackAllowed, + valid: licenseValid.valid, + errorMsg: licenseValid.msg, + type: licenseData.type, + validUntil: licensePayload.validUntil, + features: features.map((feat) => Feature[feat as keyof typeof Feature]), + enabledFeatures: enabledFeatures, + }; + } + + async licenseFeatures(ctx: TraceContext, features: string[], userCount: number): Promise { + var enabledFeatures: string[] = []; + for (const feature of features) { + const featureName: Feature = Feature[feature as keyof typeof Feature]; + if (this.licenseEvaluator.isEnabled(featureName, userCount)) { + enabledFeatures.push(featureName); + } + } + + return enabledFeatures; + } + async licenseIncludesFeature(ctx: TraceContext, feature: LicenseFeature): Promise { return false; }