From 7408d421b07e3046ea278008db184dc92f0ea116 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 16 Nov 2022 20:52:52 +0000 Subject: [PATCH] feat: Add the option to generate a trial license during setup This allows users to generate a 30 day free license during setup to test out Enterprise features. --- .github/workflows/typos.toml | 1 + .golangci.yaml | 2 + .vscode/settings.json | 1 + cli/login.go | 21 ++++- cli/login_test.go | 3 + cli/resetpassword_test.go | 7 +- cli/server_test.go | 7 +- coderd/coderd.go | 3 +- coderd/coderdtest/coderdtest.go | 9 ++- coderd/database/dump.sql | 3 +- .../migrations/000077_license_ids.down.sql | 1 + .../migrations/000077_license_ids.up.sql | 1 + coderd/database/models.go | 3 +- coderd/database/queries.sql.go | 26 ++++-- coderd/database/queries/licenses.sql | 5 +- coderd/telemetry/telemetry.go | 25 ++++++ coderd/telemetry/telemetry_test.go | 12 +++ coderd/users.go | 11 +++ coderd/users_test.go | 45 ++++++++--- codersdk/licenses.go | 3 + codersdk/users.go | 8 +- enterprise/cli/server.go | 3 + enterprise/coderd/license/license.go | 12 +-- enterprise/coderd/licenses.go | 22 ++++- enterprise/trialer/trialer.go | 80 +++++++++++++++++++ enterprise/trialer/trialer_test.go | 34 ++++++++ site/src/api/typesGenerated.ts | 3 +- site/src/pages/SetupPage/SetupPageView.tsx | 52 ++++++++---- 28 files changed, 331 insertions(+), 72 deletions(-) create mode 100644 coderd/database/migrations/000077_license_ids.down.sql create mode 100644 coderd/database/migrations/000077_license_ids.up.sql create mode 100644 enterprise/trialer/trialer.go create mode 100644 enterprise/trialer/trialer_test.go diff --git a/.github/workflows/typos.toml b/.github/workflows/typos.toml index a03505facc3c2..8f2acbd06c4e3 100644 --- a/.github/workflows/typos.toml +++ b/.github/workflows/typos.toml @@ -9,6 +9,7 @@ MacOS = "macOS" doas = "doas" darcula = "darcula" Hashi = "Hashi" +trialer = "trialer" [files] extend-exclude = [ diff --git a/.golangci.yaml b/.golangci.yaml index 5fe37e4c121e1..4181eb8319319 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -123,6 +123,8 @@ linters-settings: misspell: locale: US + ignore-words: + - trialer nestif: min-complexity: 4 # Min complexity of if statements (def 5, goal 4) diff --git a/.vscode/settings.json b/.vscode/settings.json index ba58f6f4ee1bf..7804d3958dfe3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -127,6 +127,7 @@ "tfstate", "tios", "tparallel", + "trialer", "trimprefix", "tsdial", "tslogger", diff --git a/cli/login.go b/cli/login.go index 7c9fd04550189..2c6498a63f62a 100644 --- a/cli/login.go +++ b/cli/login.go @@ -38,10 +38,13 @@ func init() { } func login() *cobra.Command { + const firstUserTrialEnv = "CODER_FIRST_USER_TRIAL" + var ( email string username string password string + trial bool ) cmd := &cobra.Command{ Use: "login ", @@ -162,11 +165,20 @@ func login() *cobra.Command { } } + if !cmd.Flags().Changed("first-user-trial") && os.Getenv(firstUserTrialEnv) == "" { + v, _ := cliui.Prompt(cmd, cliui.PromptOptions{ + Text: "Start a 30-day trial of Enterprise?", + IsConfirm: true, + Default: "yes", + }) + trial = v == "yes" || v == "y" + } + _, err = client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{ - Email: email, - Username: username, - OrganizationName: username, - Password: password, + Email: email, + Username: username, + Password: password, + Trial: trial, }) if err != nil { return xerrors.Errorf("create initial user: %w", err) @@ -251,6 +263,7 @@ func login() *cobra.Command { cliflag.StringVarP(cmd.Flags(), &email, "first-user-email", "", "CODER_FIRST_USER_EMAIL", "", "Specifies an email address to use if creating the first user for the deployment.") cliflag.StringVarP(cmd.Flags(), &username, "first-user-username", "", "CODER_FIRST_USER_USERNAME", "", "Specifies a username to use if creating the first user for the deployment.") cliflag.StringVarP(cmd.Flags(), &password, "first-user-password", "", "CODER_FIRST_USER_PASSWORD", "", "Specifies a password to use if creating the first user for the deployment.") + cliflag.BoolVarP(cmd.Flags(), &trial, "first-user-trial", "", firstUserTrialEnv, false, "Specifies whether a trial license should be provisioned for the Coder deployment or not.") return cmd } diff --git a/cli/login_test.go b/cli/login_test.go index fd2b5145ba4d9..f1ad30ff2c9a1 100644 --- a/cli/login_test.go +++ b/cli/login_test.go @@ -56,6 +56,7 @@ func TestLogin(t *testing.T) { "email", "user@coder.com", "password", "password", "password", "password", // Confirm. + "trial", "yes", } for i := 0; i < len(matches); i += 2 { match := matches[i] @@ -127,6 +128,8 @@ func TestLogin(t *testing.T) { pty.WriteLine("pass") pty.ExpectMatch("Confirm") pty.WriteLine("pass") + pty.ExpectMatch("trial") + pty.WriteLine("yes") pty.ExpectMatch("Welcome to Coder") <-doneChan }) diff --git a/cli/resetpassword_test.go b/cli/resetpassword_test.go index 508a9304d8ef3..02d4855eb02f0 100644 --- a/cli/resetpassword_test.go +++ b/cli/resetpassword_test.go @@ -60,10 +60,9 @@ func TestResetPassword(t *testing.T) { client := codersdk.New(accessURL) _, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{ - Email: email, - Username: username, - Password: oldPassword, - OrganizationName: "example", + Email: email, + Username: username, + Password: oldPassword, }) require.NoError(t, err) diff --git a/cli/server_test.go b/cli/server_test.go index 30356cd18c623..efd844a71b7e0 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -71,10 +71,9 @@ func TestServer(t *testing.T) { client := codersdk.New(accessURL) _, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{ - Email: "some@one.com", - Username: "example", - Password: "password", - OrganizationName: "example", + Email: "some@one.com", + Username: "example", + Password: "password", }) require.NoError(t, err) cancelFunc() diff --git a/coderd/coderd.go b/coderd/coderd.go index d695bb508db47..2b5ed1038c86e 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "crypto/tls" "crypto/x509" "fmt" @@ -86,7 +87,7 @@ type Options struct { AutoImportTemplates []AutoImportTemplate GitAuthConfigs []*gitauth.Config RealIPConfig *httpmw.RealIPConfig - + TrialGenerator func(ctx context.Context, email string) error // TLSCertificates is used to mesh DERP servers securely. TLSCertificates []tls.Certificate TailnetCoordinator tailnet.Coordinator diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 0f253a0ceeffe..3c8354da1743c 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -94,6 +94,7 @@ type Options struct { Auditor audit.Auditor TLSCertificates []tls.Certificate GitAuthConfigs []*gitauth.Config + TrialGenerator func(context.Context, string) error // IncludeProvisionerDaemon when true means to start an in-memory provisionerD IncludeProvisionerDaemon bool @@ -258,6 +259,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can Authorizer: options.Authorizer, Telemetry: telemetry.NewNoop(), TLSCertificates: options.TLSCertificates, + TrialGenerator: options.TrialGenerator, DERPMap: &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: { @@ -348,10 +350,9 @@ func NewProvisionerDaemon(t *testing.T, coderAPI *coderd.API) io.Closer { } var FirstUserParams = codersdk.CreateFirstUserRequest{ - Email: "testuser@coder.com", - Username: "testuser", - Password: "testpass", - OrganizationName: "testorg", + Email: "testuser@coder.com", + Username: "testuser", + Password: "testpass", } // CreateFirstUser creates a user with preset credentials and authenticates diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 0eca61e961c8a..cf08f48bd5c2c 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -200,7 +200,8 @@ CREATE TABLE licenses ( id integer NOT NULL, uploaded_at timestamp with time zone NOT NULL, jwt text NOT NULL, - exp timestamp with time zone NOT NULL + exp timestamp with time zone NOT NULL, + uuid uuid ); COMMENT ON COLUMN licenses.exp IS 'exp tracks the claim of the same name in the JWT, and we include it here so that we can easily query for licenses that have not yet expired.'; diff --git a/coderd/database/migrations/000077_license_ids.down.sql b/coderd/database/migrations/000077_license_ids.down.sql new file mode 100644 index 0000000000000..cd8c78441cc1d --- /dev/null +++ b/coderd/database/migrations/000077_license_ids.down.sql @@ -0,0 +1 @@ +ALTER TABLE licenses DROP COLUMN uuid; diff --git a/coderd/database/migrations/000077_license_ids.up.sql b/coderd/database/migrations/000077_license_ids.up.sql new file mode 100644 index 0000000000000..d39d022fa1ec1 --- /dev/null +++ b/coderd/database/migrations/000077_license_ids.up.sql @@ -0,0 +1 @@ +ALTER TABLE licenses ADD COLUMN uuid uuid; diff --git a/coderd/database/models.go b/coderd/database/models.go index 08dfdfd6d6c72..6cc569403ba42 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -467,7 +467,8 @@ type License struct { UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"` JWT string `db:"jwt" json:"jwt"` // exp tracks the claim of the same name in the JWT, and we include it here so that we can easily query for licenses that have not yet expired. - Exp time.Time `db:"exp" json:"exp"` + Exp time.Time `db:"exp" json:"exp"` + Uuid uuid.NullUUID `db:"uuid" json:"uuid"` } type Organization struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2519ddf22760b..9402e0ad06870 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1401,7 +1401,7 @@ func (q *sqlQuerier) DeleteLicense(ctx context.Context, id int32) (int32, error) } const getLicenses = `-- name: GetLicenses :many -SELECT id, uploaded_at, jwt, exp +SELECT id, uploaded_at, jwt, exp, uuid FROM licenses ORDER BY (id) ` @@ -1420,6 +1420,7 @@ func (q *sqlQuerier) GetLicenses(ctx context.Context) ([]License, error) { &i.UploadedAt, &i.JWT, &i.Exp, + &i.Uuid, ); err != nil { return nil, err } @@ -1435,7 +1436,7 @@ func (q *sqlQuerier) GetLicenses(ctx context.Context) ([]License, error) { } const getUnexpiredLicenses = `-- name: GetUnexpiredLicenses :many -SELECT id, uploaded_at, jwt, exp +SELECT id, uploaded_at, jwt, exp, uuid FROM licenses WHERE exp > NOW() ORDER BY (id) @@ -1455,6 +1456,7 @@ func (q *sqlQuerier) GetUnexpiredLicenses(ctx context.Context) ([]License, error &i.UploadedAt, &i.JWT, &i.Exp, + &i.Uuid, ); err != nil { return nil, err } @@ -1474,26 +1476,34 @@ INSERT INTO licenses ( uploaded_at, jwt, - exp + exp, + uuid ) VALUES - ($1, $2, $3) RETURNING id, uploaded_at, jwt, exp + ($1, $2, $3, $4) RETURNING id, uploaded_at, jwt, exp, uuid ` type InsertLicenseParams struct { - UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"` - JWT string `db:"jwt" json:"jwt"` - Exp time.Time `db:"exp" json:"exp"` + UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"` + JWT string `db:"jwt" json:"jwt"` + Exp time.Time `db:"exp" json:"exp"` + Uuid uuid.NullUUID `db:"uuid" json:"uuid"` } func (q *sqlQuerier) InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error) { - row := q.db.QueryRowContext(ctx, insertLicense, arg.UploadedAt, arg.JWT, arg.Exp) + row := q.db.QueryRowContext(ctx, insertLicense, + arg.UploadedAt, + arg.JWT, + arg.Exp, + arg.Uuid, + ) var i License err := row.Scan( &i.ID, &i.UploadedAt, &i.JWT, &i.Exp, + &i.Uuid, ) return i, err } diff --git a/coderd/database/queries/licenses.sql b/coderd/database/queries/licenses.sql index 39419c301761d..1622151a477f1 100644 --- a/coderd/database/queries/licenses.sql +++ b/coderd/database/queries/licenses.sql @@ -3,10 +3,11 @@ INSERT INTO licenses ( uploaded_at, jwt, - exp + exp, + uuid ) VALUES - ($1, $2, $3) RETURNING *; + ($1, $2, $3, $4) RETURNING *; -- name: GetLicenses :many SELECT * diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 0db7464771274..9d957f40eecb7 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -446,6 +446,17 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { } return nil }) + eg.Go(func() error { + licenses, err := r.options.Database.GetUnexpiredLicenses(ctx) + if err != nil { + return xerrors.Errorf("get licenses: %w", err) + } + snapshot.Licenses = make([]License, 0, len(licenses)) + for _, license := range licenses { + snapshot.Licenses = append(snapshot.Licenses, ConvertLicense(license)) + } + return nil + }) err := eg.Wait() if err != nil { @@ -622,6 +633,14 @@ func ConvertTemplateVersion(version database.TemplateVersion) TemplateVersion { return snapVersion } +// ConvertLicense anonymizes a license. +func ConvertLicense(license database.License) License { + return License{ + UploadedAt: license.UploadedAt, + UUID: license.Uuid.UUID, + } +} + // Snapshot represents a point-in-time anonymized database dump. // Data is aggregated by latest on the server-side, so partial data // can be sent without issue. @@ -631,6 +650,7 @@ type Snapshot struct { APIKeys []APIKey `json:"api_keys"` ParameterSchemas []ParameterSchema `json:"parameter_schemas"` ProvisionerJobs []ProvisionerJob `json:"provisioner_jobs"` + Licenses []License `json:"licenses"` Templates []Template `json:"templates"` TemplateVersions []TemplateVersion `json:"template_versions"` Users []User `json:"users"` @@ -791,6 +811,11 @@ type ParameterSchema struct { ValidationCondition string `json:"validation_condition"` } +type License struct { + UploadedAt time.Time `json:"uploaded_at"` + UUID uuid.UUID `json:"uuid"` +} + type noopReporter struct{} func (*noopReporter) Report(_ *Snapshot) {} diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index ddfccf68100e9..5cdc36f22df95 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "net/url" "testing" + "time" "github.com/go-chi/chi" "github.com/google/uuid" @@ -87,9 +88,20 @@ func TestTelemetry(t *testing.T) { CreatedAt: database.Now(), }) require.NoError(t, err) + _, err = db.InsertLicense(ctx, database.InsertLicenseParams{ + UploadedAt: database.Now(), + JWT: "", + Exp: database.Now().Add(time.Hour), + Uuid: uuid.NullUUID{ + UUID: uuid.New(), + Valid: true, + }, + }) + require.NoError(t, err) snapshot := collectSnapshot(t, db) require.Len(t, snapshot.ParameterSchemas, 1) require.Len(t, snapshot.ProvisionerJobs, 1) + require.Len(t, snapshot.Licenses, 1) require.Len(t, snapshot.Templates, 1) require.Len(t, snapshot.TemplateVersions, 1) require.Len(t, snapshot.Users, 1) diff --git a/coderd/users.go b/coderd/users.go index ed2acd8778aeb..b3e42cba75c91 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -80,6 +80,17 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { return } + if createUser.Trial && api.TrialGenerator != nil { + err = api.TrialGenerator(ctx, createUser.Email) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to generate trial", + Detail: err.Error(), + }) + return + } + } + user, organizationID, err := api.CreateUser(ctx, api.Database, CreateUserRequest{ CreateUserRequest: codersdk.CreateUserRequest{ Email: createUser.Email, diff --git a/coderd/users_test.go b/coderd/users_test.go index ba6de76659458..1211c35ac1f95 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -49,10 +49,9 @@ func TestFirstUser(t *testing.T) { defer cancel() _, err := client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{ - Email: "some@email.com", - Username: "exampleuser", - Password: "password", - OrganizationName: "someorg", + Email: "some@email.com", + Username: "exampleuser", + Password: "password", }) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) @@ -65,6 +64,30 @@ func TestFirstUser(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) }) + t.Run("Trial", func(t *testing.T) { + t.Parallel() + called := make(chan struct{}) + client := coderdtest.New(t, &coderdtest.Options{ + TrialGenerator: func(ctx context.Context, s string) error { + close(called) + return nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + req := codersdk.CreateFirstUserRequest{ + Email: "testuser@coder.com", + Username: "testuser", + Password: "testpass", + Trial: true, + } + _, err := client.CreateFirstUser(ctx, req) + require.NoError(t, err) + <-called + }) + t.Run("LastSeenAt", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -192,10 +215,9 @@ func TestPostLogin(t *testing.T) { defer cancel() req := codersdk.CreateFirstUserRequest{ - Email: "testuser@coder.com", - Username: "testuser", - Password: "testpass", - OrganizationName: "testorg", + Email: "testuser@coder.com", + Username: "testuser", + Password: "testpass", } _, err := client.CreateFirstUser(ctx, req) require.NoError(t, err) @@ -249,10 +271,9 @@ func TestPostLogin(t *testing.T) { defer cancel() req := codersdk.CreateFirstUserRequest{ - Email: "testuser@coder.com", - Username: "testuser", - Password: "testpass", - OrganizationName: "testorg", + Email: "testuser@coder.com", + Username: "testuser", + Password: "testpass", } _, err := client.CreateFirstUser(ctx, req) require.NoError(t, err) diff --git a/codersdk/licenses.go b/codersdk/licenses.go index fe959c4108dd6..73118de5fdb32 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "time" + + "github.com/google/uuid" ) type AddLicenseRequest struct { @@ -14,6 +16,7 @@ type AddLicenseRequest struct { type License struct { ID int32 `json:"id"` + UUID uuid.UUID `json:"uuid"` UploadedAt time.Time `json:"uploaded_at"` // Claims are the JWT claims asserted by the license. Here we use // a generic string map to ensure that all data from the server is diff --git a/codersdk/users.go b/codersdk/users.go index d1ea338410826..6561c506fee0b 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -53,10 +53,10 @@ type GetUsersResponse struct { } type CreateFirstUserRequest struct { - Email string `json:"email" validate:"required,email"` - Username string `json:"username" validate:"required,username"` - Password string `json:"password" validate:"required"` - OrganizationName string `json:"organization" validate:"required,username"` + Email string `json:"email" validate:"required,email"` + Username string `json:"username" validate:"required,username"` + Password string `json:"password" validate:"required"` + Trial bool `json:"trial"` } // CreateFirstUserResponse contains IDs for newly created user info. diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 4a2d41a7eceea..847e7a9345f4c 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -17,6 +17,7 @@ import ( "github.com/coder/coder/enterprise/audit" "github.com/coder/coder/enterprise/audit/backends" "github.com/coder/coder/enterprise/coderd" + "github.com/coder/coder/enterprise/trialer" "github.com/coder/coder/tailnet" agpl "github.com/coder/coder/cli" @@ -57,6 +58,8 @@ func server() *cobra.Command { ) } + options.TrialGenerator = trialer.New(options.Database, "https://v2-licensor.coder.com/trial", coderd.Keys) + o := &coderd.Options{ AuditLogging: options.DeploymentConfig.AuditLogging.Value, BrowserOnly: options.DeploymentConfig.BrowserOnly.Value, diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 5307c490e3ae5..a87dbeeba2979 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -54,7 +54,7 @@ func Entitlements( // Here we loop through licenses to detect enabled features. for _, l := range licenses { - claims, err := validateDBLicense(l, keys) + claims, err := ParseClaims(l.JWT, keys) if err != nil { logger.Debug(ctx, "skipping invalid license", slog.F("id", l.ID), slog.Error(err)) @@ -263,8 +263,8 @@ type Claims struct { Features Features `json:"features"` } -// Parse consumes a license and returns the claims. -func Parse(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, error) { +// ParseRaw consumes a license and returns the claims. +func ParseRaw(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, error) { tok, err := jwt.Parse( l, keyFunc(keys), @@ -286,11 +286,11 @@ func Parse(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, error) { return nil, xerrors.New("unable to parse Claims") } -// validateDBLicense validates a database.License record, and if valid, returns the claims. If +// ParseClaims validates a database.License record, and if valid, returns the claims. If // unparsable or invalid, it returns an error -func validateDBLicense(l database.License, keys map[string]ed25519.PublicKey) (*Claims, error) { +func ParseClaims(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, error) { tok, err := jwt.ParseWithClaims( - l.JWT, + rawJWT, &Claims{}, keyFunc(keys), jwt.WithValidMethods(ValidMethods), diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index f56df142cd234..28a732e2b88db 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -15,6 +15,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" "golang.org/x/xerrors" "cdr.dev/slog" @@ -59,7 +60,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { return } - claims, err := license.Parse(addLicense.License, api.Keys) + rawClaims, err := license.ParseRaw(addLicense.License, api.Keys) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid license", @@ -67,7 +68,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { }) return } - exp, ok := claims["exp"].(float64) + exp, ok := rawClaims["exp"].(float64) if !ok { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid license", @@ -77,10 +78,24 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { } expTime := time.Unix(int64(exp), 0) + claims, err := license.ParseClaims(addLicense.License, api.Keys) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid license", + Detail: err.Error(), + }) + return + } + + id, err := uuid.Parse(claims.ID) dl, err := api.Database.InsertLicense(ctx, database.InsertLicenseParams{ UploadedAt: database.Now(), JWT: addLicense.License, Exp: expTime, + Uuid: uuid.NullUUID{ + UUID: id, + Valid: err == nil, + }, }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -103,7 +118,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { // don't fail the HTTP request, since we did write it successfully to the database } - httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, claims)) + httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, rawClaims)) } func (api *API) licenses(rw http.ResponseWriter, r *http.Request) { @@ -189,6 +204,7 @@ func (api *API) deleteLicense(rw http.ResponseWriter, r *http.Request) { func convertLicense(dl database.License, c jwt.MapClaims) codersdk.License { return codersdk.License{ ID: dl.ID, + UUID: dl.Uuid.UUID, UploadedAt: dl.UploadedAt, Claims: c, } diff --git a/enterprise/trialer/trialer.go b/enterprise/trialer/trialer.go new file mode 100644 index 0000000000000..1ee49343d823b --- /dev/null +++ b/enterprise/trialer/trialer.go @@ -0,0 +1,80 @@ +package trialer + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "io" + "net/http" + "time" + + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/enterprise/coderd/license" + "github.com/google/uuid" +) + +type request struct { + DeploymentID string `json:"deployment_id"` + Email string `json:"email"` +} + +// New creates a handler that can issue trial licenses! +func New(db database.Store, url string, keys map[string]ed25519.PublicKey) func(ctx context.Context, email string) error { + return func(ctx context.Context, email string) error { + deploymentID, err := db.GetDeploymentID(ctx) + if err != nil { + return xerrors.Errorf("get deployment id: %w", err) + } + data, err := json.Marshal(request{ + DeploymentID: deploymentID, + Email: email, + }) + if err != nil { + return xerrors.Errorf("marshal: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return xerrors.Errorf("create license request: %w", err) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return xerrors.Errorf("perform license request: %w", err) + } + defer res.Body.Close() + raw, err := io.ReadAll(res.Body) + if err != nil { + return xerrors.Errorf("read license: %w", err) + } + rawClaims, err := license.ParseRaw(string(raw), keys) + if err != nil { + return xerrors.Errorf("parse license: %w", err) + } + exp, ok := rawClaims["exp"].(float64) + if !ok { + return xerrors.New("invalid license missing exp claim") + } + expTime := time.Unix(int64(exp), 0) + + claims, err := license.ParseClaims(string(raw), keys) + if err != nil { + return xerrors.Errorf("parse claims: %w", err) + } + id, err := uuid.Parse(claims.ID) + _, err = db.InsertLicense(ctx, database.InsertLicenseParams{ + UploadedAt: database.Now(), + JWT: string(raw), + Exp: expTime, + Uuid: uuid.NullUUID{ + UUID: id, + Valid: err == nil, + }, + }) + if err != nil { + return xerrors.Errorf("insert license: %w", err) + } + return nil + } +} diff --git a/enterprise/trialer/trialer_test.go b/enterprise/trialer/trialer_test.go new file mode 100644 index 0000000000000..7042e9cec4782 --- /dev/null +++ b/enterprise/trialer/trialer_test.go @@ -0,0 +1,34 @@ +package trialer_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/enterprise/trialer" +) + +func TestTrialer(t *testing.T) { + t.Parallel() + license := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + Trial: true, + }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(license)) + })) + defer srv.Close() + db := databasefake.New() + + gen := trialer.New(db, srv.URL, coderdenttest.Keys) + err := gen(context.Background(), "kyle@coder.com") + require.NoError(t, err) + licenses, err := db.GetLicenses(context.Background()) + require.NoError(t, err) + require.Len(t, licenses, 1) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0e27b3853801d..267b4bed53791 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -143,7 +143,7 @@ export interface CreateFirstUserRequest { readonly email: string readonly username: string readonly password: string - readonly organization: string + readonly trial: boolean } // From codersdk/users.go @@ -395,6 +395,7 @@ export interface Healthcheck { // From codersdk/licenses.go export interface License { readonly id: number + readonly uuid: string readonly uploaded_at: string // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO explain why this is needed readonly claims: Record diff --git a/site/src/pages/SetupPage/SetupPageView.tsx b/site/src/pages/SetupPage/SetupPageView.tsx index 9e8656a581bd6..5d4ed2d1eeb7d 100644 --- a/site/src/pages/SetupPage/SetupPageView.tsx +++ b/site/src/pages/SetupPage/SetupPageView.tsx @@ -1,5 +1,9 @@ +import Box from "@material-ui/core/Box" +import Checkbox from "@material-ui/core/Checkbox" import FormHelperText from "@material-ui/core/FormHelperText" +import { makeStyles } from "@material-ui/core/styles" import TextField from "@material-ui/core/TextField" +import Typography from "@material-ui/core/Typography" import { LoadingButton } from "components/LoadingButton/LoadingButton" import { SignInLayout } from "components/SignInLayout/SignInLayout" import { Stack } from "components/Stack/Stack" @@ -13,17 +17,11 @@ export const Language = { emailLabel: "Email", passwordLabel: "Password", usernameLabel: "Username", - organizationLabel: "Organization name", emailInvalid: "Please enter a valid email address.", emailRequired: "Please enter an email address.", passwordRequired: "Please enter a password.", - organizationRequired: "Please enter an organization name.", create: "Setup account", - welcomeMessage: ( - <> - Set up your account - - ), + welcomeMessage: <>Welcome to Coder, } const validationSchema = Yup.object({ @@ -32,7 +30,6 @@ const validationSchema = Yup.object({ .email(Language.emailInvalid) .required(Language.emailRequired), password: Yup.string().required(Language.passwordRequired), - organization: Yup.string().required(Language.organizationRequired), username: nameValidator(Language.usernameLabel), }) @@ -55,7 +52,7 @@ export const SetupPageView: React.FC = ({ email: "", password: "", username: "", - organization: "", + trial: true, }, validationSchema, onSubmit, @@ -64,20 +61,13 @@ export const SetupPageView: React.FC = ({ form, formErrors, ) + const styles = useStyles() return (
- = ({ {genericError && ( {genericError} )} +
+ +
+ +
+ + + + Start a 30-day free trial of Enterprise + + + Get access to high availability, template RBAC, audit logging, + quotas, and more. + + +
+
= ({ ) } + +const useStyles = makeStyles(() => ({ + callout: { + borderRadius: 16, + }, +}))