diff --git a/.github/workflows/typos.toml b/.github/workflows/typos.toml index a03505facc..8f2acbd06c 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 5fe37e4c12..4181eb8319 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 09aab5fcbc..ca19998fa6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -128,6 +128,7 @@ "tfstate", "tios", "tparallel", + "trialer", "trimprefix", "tsdial", "tslogger", diff --git a/cli/login.go b/cli/login.go index 7c9fd04550..2c6498a63f 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 fd2b5145ba..540adc13c2 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] @@ -74,7 +75,7 @@ func TestLogin(t *testing.T) { // accurately detect Windows ptys when they are not attached to a process: // https://github.com/mattn/go-isatty/issues/59 doneChan := make(chan struct{}) - root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "password") + root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "password", "--first-user-trial") pty := ptytest.New(t) root.SetIn(pty.Input()) root.SetOut(pty.Output()) @@ -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 508a9304d8..02d4855eb0 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 30356cd18c..efd844a71b 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 077e8ecfc1..3f7d3d7211 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -94,7 +94,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 abb8fec482..7e69d72813 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: { @@ -383,10 +385,9 @@ func NewExternalProvisionerDaemon(t *testing.T, client *codersdk.Client, org uui } 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 cece101690..13ad911f39 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/000080_license_ids.down.sql b/coderd/database/migrations/000080_license_ids.down.sql new file mode 100644 index 0000000000..cd8c78441c --- /dev/null +++ b/coderd/database/migrations/000080_license_ids.down.sql @@ -0,0 +1 @@ +ALTER TABLE licenses DROP COLUMN uuid; diff --git a/coderd/database/migrations/000080_license_ids.up.sql b/coderd/database/migrations/000080_license_ids.up.sql new file mode 100644 index 0000000000..d39d022fa1 --- /dev/null +++ b/coderd/database/migrations/000080_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 9b6f2cc1b1..209049daab 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -468,7 +468,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 30c577becd..207fe622bb 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1402,7 +1402,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) ` @@ -1421,6 +1421,7 @@ func (q *sqlQuerier) GetLicenses(ctx context.Context) ([]License, error) { &i.UploadedAt, &i.JWT, &i.Exp, + &i.Uuid, ); err != nil { return nil, err } @@ -1436,7 +1437,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) @@ -1456,6 +1457,7 @@ func (q *sqlQuerier) GetUnexpiredLicenses(ctx context.Context) ([]License, error &i.UploadedAt, &i.JWT, &i.Exp, + &i.Uuid, ); err != nil { return nil, err } @@ -1475,26 +1477,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 39419c3017..1622151a47 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 0db7464771..9d957f40ee 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 ddfccf6810..5cdc36f22d 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 ed2acd8778..b3e42cba75 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 ba6de76659..1211c35ac1 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 fe959c4108..73118de5fd 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 d1ea338410..6561c506fe 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 4a2d41a7ec..847e7a9345 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 ab37354a46..1d3c5e4def 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)) @@ -270,8 +270,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), @@ -293,11 +293,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 f56df142cd..28a732e2b8 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 0000000000..1ee49343d8 --- /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 0000000000..7042e9cec4 --- /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 7faaf7357c..75c85198b0 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 @@ -396,6 +396,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/SetupPage.test.tsx b/site/src/pages/SetupPage/SetupPage.test.tsx index 2486926db0..2344e2e596 100644 --- a/site/src/pages/SetupPage/SetupPage.test.tsx +++ b/site/src/pages/SetupPage/SetupPage.test.tsx @@ -12,20 +12,14 @@ const fillForm = async ({ username = "someuser", email = "someone@coder.com", password = "password", - organization = "Coder", }: { username?: string email?: string password?: string - organization?: string } = {}) => { const usernameField = screen.getByLabelText(PageViewLanguage.usernameLabel) const emailField = screen.getByLabelText(PageViewLanguage.emailLabel) const passwordField = screen.getByLabelText(PageViewLanguage.passwordLabel) - const organizationField = screen.getByLabelText( - PageViewLanguage.organizationLabel, - ) - await userEvent.type(organizationField, organization) await userEvent.type(usernameField, username) await userEvent.type(emailField, email) await userEvent.type(passwordField, password) diff --git a/site/src/pages/SetupPage/SetupPageView.tsx b/site/src/pages/SetupPage/SetupPageView.tsx index 9e8656a581..6b0414a033 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, + }, +}))