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 09aab5fcbc198..ca19998fa60c9 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 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..540adc13c2218 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 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 077e8ecfc182c..3f7d3d7211321 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 abb8fec4828c8..7e69d72813f31 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 cece10169071e..13ad911f3949d 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 0000000000000..cd8c78441cc1d --- /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 0000000000000..d39d022fa1ec1 --- /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 9b6f2cc1b163e..209049daab428 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 30c577becdbc6..207fe622bb423 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 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 ab37354a46bbe..1d3c5e4def11b 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 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 7faaf7357c22c..75c85198b0626 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 2486926db0211..2344e2e596d70 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 9e8656a581bd6..6b0414a0338f9 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, + }, +}))