diff --git a/cli/create_test.go b/cli/create_test.go index cd069706ea71e..453bdbfbd1b9e 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -2,7 +2,6 @@ package cli_test import ( "context" - "database/sql" "fmt" "os" "testing" @@ -13,7 +12,6 @@ import ( "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" @@ -255,44 +253,42 @@ func TestCreate(t *testing.T) { t.Run("FailedDryRun", func(t *testing.T) { t.Parallel() - client, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerD: true}) + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, + Parse: []*proto.Parse_Response{{ + Type: &proto.Parse_Response_Complete{ + Complete: &proto.Parse_Complete{ + ParameterSchemas: echo.ParameterSuccess, + }, + }, + }}, ProvisionDryRun: []*proto.Provision_Response{ { Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Error: "test error", - }, + Complete: &proto.Provision_Complete{}, }, }, }, }) + tempDir := t.TempDir() + parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml") + _, _ = parameterFile.WriteString(fmt.Sprintf("%s: %q", echo.ParameterExecKey, echo.ParameterError("fail"))) + // The template import job should end up failed, but we need it to be // succeeded so the dry-run can begin. version = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - require.Equal(t, codersdk.ProvisionerJobFailed, version.Job.Status, "job is not failed") - err := api.Database.UpdateProvisionerJobWithCompleteByID(context.Background(), database.UpdateProvisionerJobWithCompleteByIDParams{ - ID: version.Job.ID, - CompletedAt: sql.NullTime{ - Time: time.Now(), - Valid: true, - }, - UpdatedAt: time.Now(), - Error: sql.NullString{}, - }) - require.NoError(t, err, "update provisioner job") + require.Equal(t, codersdk.ProvisionerJobSucceeded, version.Job.Status, "job is not failed") _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - cmd, root := clitest.New(t, "create", "test") + cmd, root := clitest.New(t, "create", "test", "--parameter-file", parameterFile.Name()) clitest.SetupConfig(t, client, root) pty := ptytest.New(t) cmd.SetIn(pty.Input()) cmd.SetOut(pty.Output()) - err = cmd.Execute() + err := cmd.Execute() require.Error(t, err) require.ErrorContains(t, err, "dry-run workspace") }) diff --git a/cmd/coder/.gitignore b/cmd/coder/.gitignore new file mode 100644 index 0000000000000..972bf968c8cdd --- /dev/null +++ b/cmd/coder/.gitignore @@ -0,0 +1 @@ +coder diff --git a/coderd/coderd.go b/coderd/coderd.go index acf39fd8bbead..79b0766f11842 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -287,7 +287,11 @@ func New(options *Options) *API { r.Post("/authorization", api.checkPermissions) - r.Post("/keys", api.postAPIKey) + r.Route("/keys", func(r chi.Router) { + r.Post("/", api.postAPIKey) + r.Get("/{keyid}", api.apiKey) + }) + r.Route("/organizations", func(r chi.Router) { r.Get("/", api.organizationsByUser) r.Get("/{organizationname}", api.organizationByUserAndName) diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index 1a211f6bd69b2..448d6d04a3524 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -2,8 +2,14 @@ package coderd_test import ( "context" + "crypto/x509" + "database/sql" "io" + "net" "net/http" + "net/http/httptest" + "net/url" + "os" "strconv" "strings" "testing" @@ -14,10 +20,23 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/goleak" "golang.org/x/xerrors" + "google.golang.org/api/idtoken" + "google.golang.org/api/option" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/buildinfo" + "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/autobuild/executor" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/database/postgres" + "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/telemetry" + "github.com/coder/coder/coderd/turnconn" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" @@ -39,13 +58,96 @@ func TestBuildInfo(t *testing.T) { // TestAuthorizeAllEndpoints will check `authorize` is called on every endpoint registered. func TestAuthorizeAllEndpoints(t *testing.T) { t.Parallel() - ctx := context.Background() + var ( + ctx = context.Background() + authorizer = &fakeAuthorizer{} + ) - authorizer := &fakeAuthorizer{} - client, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Authorizer: authorizer, - IncludeProvisionerD: true, - }) + // This function was taken from coderdtest.newWithAPI. It is intentionally + // copied to avoid exposing the API to other tests in coderd. Tests should + // not need a reference to coderd.API...this test is an exception. + newClient := func(authorizer rbac.Authorizer) (*codersdk.Client, *coderd.API) { + // This can be hotswapped for a live database instance. + db := databasefake.New() + pubsub := database.NewPubsubInMemory() + if os.Getenv("DB") != "" { + connectionURL, closePg, err := postgres.Open() + require.NoError(t, err) + t.Cleanup(closePg) + sqlDB, err := sql.Open("postgres", connectionURL) + require.NoError(t, err) + t.Cleanup(func() { + _ = sqlDB.Close() + }) + err = database.MigrateUp(sqlDB) + require.NoError(t, err) + db = database.New(sqlDB) + + pubsub, err = database.NewPubsub(context.Background(), sqlDB, connectionURL) + require.NoError(t, err) + t.Cleanup(func() { + _ = pubsub.Close() + }) + } + + tickerCh := make(chan time.Time) + t.Cleanup(func() { close(tickerCh) }) + + ctx, cancelFunc := context.WithCancel(context.Background()) + lifecycleExecutor := executor.New( + ctx, + db, + slogtest.Make(t, nil).Named("autobuild.executor").Leveled(slog.LevelDebug), + tickerCh, + ).WithStatsChannel(nil) + lifecycleExecutor.Run() + + srv := httptest.NewUnstartedServer(nil) + srv.Config.BaseContext = func(_ net.Listener) context.Context { + return ctx + } + srv.Start() + serverURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + turnServer, err := turnconn.New(nil) + require.NoError(t, err) + + validator, err := idtoken.NewValidator(ctx, option.WithoutAuthentication()) + require.NoError(t, err) + + // We set the handler after server creation for the access URL. + coderAPI := coderd.New(&coderd.Options{ + AgentConnectionUpdateFrequency: 150 * time.Millisecond, + AccessURL: serverURL, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + Database: db, + Pubsub: pubsub, + + AWSCertificates: nil, + AzureCertificates: x509.VerifyOptions{}, + GithubOAuth2Config: nil, + GoogleTokenValidator: validator, + SSHKeygenAlgorithm: gitsshkey.AlgorithmEd25519, + TURNServer: turnServer, + APIRateLimit: 0, + Authorizer: authorizer, + Telemetry: telemetry.NewNoop(), + }) + srv.Config.Handler = coderAPI.Handler + + _ = coderdtest.NewProvisionerDaemon(t, coderAPI) + t.Cleanup(func() { + cancelFunc() + _ = turnServer.Close() + srv.Close() + _ = coderAPI.Close() + }) + + return codersdk.New(serverURL), coderAPI + } + + client, api := newClient(authorizer) admin := coderdtest.CreateFirstUser(t, client) // The provisioner will call to coderd and register itself. This is async, // so we wait for it to occur. diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 6a25395688508..d06007c50c3e4 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -74,12 +74,31 @@ type Options struct { // New constructs a codersdk client connected to an in-memory API instance. func New(t *testing.T, options *Options) *codersdk.Client { - client, _ := NewWithAPI(t, options) + client, _ := newWithCloser(t, options) return client } -// NewWithAPI constructs a codersdk client connected to the returned in-memory API instance. -func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, *coderd.API) { +// NewWithProvisionerCloser returns a client as well as a handle to close +// the provisioner. This is a temporary function while work is done to +// standardize how provisioners are registered with coderd. The option +// to include a provisioner is set to true for convenience. +func NewWithProvisionerCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer) { + if options == nil { + options = &Options{} + } + options.IncludeProvisionerD = true + client, closer := newWithCloser(t, options) + return client, closer +} + +// newWithCloser constructs a codersdk client connected to an in-memory API instance. +// The returned closer closes a provisioner if it was provided +// The API is intentionally not returned here because coderd tests should not +// require a handle to the API. Do not expose the API or wrath shall descend +// upon thee. Even the io.Closer that is exposed here shouldn't be exposed +// and is a temporary measure while the API to register provisioners is ironed +// out. +func newWithCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer) { if options == nil { options = &Options{} } @@ -169,17 +188,21 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, *coderd.API) Telemetry: telemetry.NewNoop(), }) srv.Config.Handler = coderAPI.Handler + + var provisionerCloser io.Closer = nopcloser{} if options.IncludeProvisionerD { - _ = NewProvisionerDaemon(t, coderAPI) + provisionerCloser = NewProvisionerDaemon(t, coderAPI) } + t.Cleanup(func() { cancelFunc() _ = turnServer.Close() srv.Close() _ = coderAPI.Close() + _ = provisionerCloser.Close() }) - return codersdk.New(serverURL), coderAPI + return codersdk.New(serverURL), provisionerCloser } // NewProvisionerDaemon launches a provisionerd instance configured to work @@ -648,3 +671,7 @@ type roundTripper func(req *http.Request) (*http.Response, error) func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return r(req) } + +type nopcloser struct{} + +func (nopcloser) Close() error { return nil } diff --git a/coderd/coderdtest/coderdtest_test.go b/coderd/coderdtest/coderdtest_test.go index 633f3f906ba8e..c9577e039210f 100644 --- a/coderd/coderdtest/coderdtest_test.go +++ b/coderd/coderdtest/coderdtest_test.go @@ -14,9 +14,10 @@ func TestMain(m *testing.M) { func TestNew(t *testing.T) { t.Parallel() - client, coderAPI := coderdtest.NewWithAPI(t, nil) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerD: true, + }) user := coderdtest.CreateFirstUser(t, client) - closer := coderdtest.NewProvisionerDaemon(t, coderAPI) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -25,5 +26,4 @@ func TestNew(t *testing.T) { coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) _, _ = coderdtest.NewGoogleInstanceIdentity(t, "example", false) _, _ = coderdtest.NewAWSInstanceIdentity(t, "an-instance") - closer.Close() } diff --git a/coderd/gitsshkey_test.go b/coderd/gitsshkey_test.go index 9f767c0d66e73..dbbcc824074f1 100644 --- a/coderd/gitsshkey_test.go +++ b/coderd/gitsshkey_test.go @@ -79,9 +79,10 @@ func TestGitSSHKey(t *testing.T) { func TestAgentGitSSHKey(t *testing.T) { t.Parallel() - client, coderAPI := coderdtest.NewWithAPI(t, nil) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerD: true, + }) user := coderdtest.CreateFirstUser(t, client) - daemonCloser := coderdtest.NewProvisionerDaemon(t, coderAPI) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -107,7 +108,6 @@ func TestAgentGitSSHKey(t *testing.T) { coderdtest.AwaitTemplateVersionJob(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - daemonCloser.Close() agentClient := codersdk.New(client.URL) agentClient.SessionToken = authToken diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index c868cc65ac7fc..a2cc37fc49931 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -2,7 +2,6 @@ package coderd_test import ( "context" - "database/sql" "net/http" "testing" "time" @@ -12,7 +11,6 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" @@ -555,18 +553,30 @@ func TestTemplateVersionDryRun(t *testing.T) { t.Run("OK", func(t *testing.T) { t.Parallel() - client, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerD: true}) + client, closer := coderdtest.NewWithProvisionerCloser(t, nil) + defer closer.Close() + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - Provision: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ - Log: &proto.Log{}, + Provision: []*proto.Provision_Response{ + { + Type: &proto.Provision_Response_Log{ + Log: &proto.Log{}, + }}, + { + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{}, + }, }, - }}, + }, }) - forceCompleteTemplateVersionJob(t, api.Database, client, version) + version = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + require.Equal(t, codersdk.ProvisionerJobSucceeded, version.Job.Status) + + closer.Close() // Create the dry-run job, err := client.CreateTemplateVersionDryRun(context.Background(), version.ID, codersdk.CreateTemplateVersionDryRunRequest{ ParameterValues: []codersdk.CreateParameterRequest{}, @@ -578,7 +588,7 @@ func TestTemplateVersionDryRun(t *testing.T) { assert.NoError(t, err) t.Logf("Status: %s", job.Status) - return job.Status == codersdk.ProvisionerJobRunning + return job.Status == codersdk.ProvisionerJobPending }, 5*time.Second, 25*time.Millisecond) err = client.CancelTemplateVersionDryRun(context.Background(), version.ID, job.ID) @@ -589,7 +599,7 @@ func TestTemplateVersionDryRun(t *testing.T) { assert.NoError(t, err) t.Logf("Status: %s", job.Status) - return job.Status == codersdk.ProvisionerJobCanceled + return job.Status == codersdk.ProvisionerJobCanceling }, 5*time.Second, 25*time.Millisecond) }) @@ -622,17 +632,29 @@ func TestTemplateVersionDryRun(t *testing.T) { t.Run("AlreadyCanceled", func(t *testing.T) { t.Parallel() - client, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerD: true}) + client, closer := coderdtest.NewWithProvisionerCloser(t, nil) + defer closer.Close() + user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - Provision: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ - Log: &proto.Log{}, + Provision: []*proto.Provision_Response{ + { + Type: &proto.Provision_Response_Log{ + Log: &proto.Log{}, + }}, + { + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{}, + }, }, - }}, + }, }) - forceCompleteTemplateVersionJob(t, api.Database, client, version) + + version = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + require.Equal(t, codersdk.ProvisionerJobSucceeded, version.Job.Status) + + closer.Close() // Create the dry-run job, err := client.CreateTemplateVersionDryRun(context.Background(), version.ID, codersdk.CreateTemplateVersionDryRunRequest{ @@ -753,23 +775,3 @@ func TestPaginatedTemplateVersions(t *testing.T) { }) } } - -func forceCompleteTemplateVersionJob(t *testing.T, db database.Store, client *codersdk.Client, version codersdk.TemplateVersion) { - t.Helper() - - // HACK: we need the template version job to be finished so the dry-run job - // can be created. We do this by canceling the job and then marking it as - // successful. - err := client.CancelTemplateVersion(context.Background(), version.ID) - require.NoError(t, err) - err = db.UpdateProvisionerJobWithCompleteByID(context.Background(), database.UpdateProvisionerJobWithCompleteByIDParams{ - ID: version.Job.ID, - UpdatedAt: time.Now(), - CompletedAt: sql.NullTime{ - Time: time.Now(), - Valid: true, - }, - Error: sql.NullString{}, - }) - require.NoError(t, err) -} diff --git a/coderd/users.go b/coderd/users.go index 9cf13dcdb780b..c8ac9aa27c1d1 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -719,6 +719,34 @@ func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) { httpapi.Write(rw, http.StatusCreated, codersdk.GenerateAPIKeyResponse{Key: sessionToken}) } +func (api *API) apiKey(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + ) + + if !api.Authorize(r, rbac.ActionRead, rbac.ResourceAPIKey.WithOwner(user.ID.String())) { + httpapi.ResourceNotFound(rw) + return + } + + keyID := chi.URLParam(r, "keyid") + key, err := api.Database.GetAPIKeyByID(ctx, keyID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: "Internal error fetching API key.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(rw, http.StatusOK, convertAPIKey(key)) +} + // Clear the user's session cookie. func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) { // Get a blank token cookie. @@ -1008,3 +1036,16 @@ func parseUserStatus(v string) ([]database.UserStatus, error) { } return statuses, nil } + +func convertAPIKey(k database.APIKey) codersdk.APIKey { + return codersdk.APIKey{ + ID: k.ID, + UserID: k.UserID, + LastUsed: k.LastUsed, + ExpiresAt: k.ExpiresAt, + CreatedAt: k.CreatedAt, + UpdatedAt: k.UpdatedAt, + LoginType: codersdk.LoginType(k.LoginType), + LifetimeSeconds: k.LifetimeSeconds, + } +} diff --git a/coderd/users_test.go b/coderd/users_test.go index 56f5c5efa55d9..d8bd106127a81 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -2,7 +2,6 @@ package coderd_test import ( "context" - "database/sql" "fmt" "net/http" "sort" @@ -14,8 +13,6 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/databasefake" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" @@ -138,94 +135,24 @@ func TestPostLogin(t *testing.T) { var ( ctx = context.Background() ) - client, api := coderdtest.NewWithAPI(t, nil) + client := coderdtest.New(t, nil) admin := coderdtest.CreateFirstUser(t, client) split := strings.Split(client.SessionToken, "-") - loginKey, err := api.Database.GetAPIKeyByID(ctx, split[0]) + key, err := client.GetAPIKey(ctx, admin.UserID.String(), split[0]) require.NoError(t, err, "fetch login key") - require.Equal(t, int64(86400), loginKey.LifetimeSeconds, "default should be 86400") + require.Equal(t, int64(86400), key.LifetimeSeconds, "default should be 86400") // Generated tokens have a longer life token, err := client.CreateAPIKey(ctx, admin.UserID.String()) require.NoError(t, err, "make new api key") split = strings.Split(token.Key, "-") - apiKey, err := api.Database.GetAPIKeyByID(ctx, split[0]) + apiKey, err := client.GetAPIKey(ctx, admin.UserID.String(), split[0]) require.NoError(t, err, "fetch api key") require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*24*6)), "api key lasts more than 6 days") - require.True(t, apiKey.ExpiresAt.After(loginKey.ExpiresAt.Add(time.Hour)), "api key should be longer expires") - require.Greater(t, apiKey.LifetimeSeconds, loginKey.LifetimeSeconds, "api key should have longer lifetime") - }) - - t.Run("APIKeyExtend", func(t *testing.T) { - t.Parallel() - var ( - ctx = context.Background() - ) - client, api := coderdtest.NewWithAPI(t, nil) - admin := coderdtest.CreateFirstUser(t, client) - - token, err := client.CreateAPIKey(ctx, admin.UserID.String()) - require.NoError(t, err, "make new api key") - client.SessionToken = token.Key - split := strings.Split(token.Key, "-") - - apiKey, err := api.Database.GetAPIKeyByID(ctx, split[0]) - require.NoError(t, err, "fetch api key") - - err = api.Database.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{ - ID: apiKey.ID, - LastUsed: apiKey.LastUsed, - IPAddress: apiKey.IPAddress, - // This should cause a refresh - ExpiresAt: apiKey.ExpiresAt.Add(time.Hour * -2), - OAuthAccessToken: apiKey.OAuthAccessToken, - OAuthRefreshToken: apiKey.OAuthRefreshToken, - OAuthExpiry: apiKey.OAuthExpiry, - }) - require.NoError(t, err, "update api key") - - _, err = client.User(ctx, codersdk.Me) - require.NoError(t, err, "fetch user") - - apiKey, err = api.Database.GetAPIKeyByID(ctx, split[0]) - require.NoError(t, err, "fetch refreshed api key") - // 1 minute tolerance - require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*24*7).Add(time.Minute*-1)), "api key lasts 7 days") - }) - - t.Run("LoginKeyExtend", func(t *testing.T) { - t.Parallel() - var ( - ctx = context.Background() - ) - client, api := coderdtest.NewWithAPI(t, nil) - _ = coderdtest.CreateFirstUser(t, client) - split := strings.Split(client.SessionToken, "-") - - apiKey, err := api.Database.GetAPIKeyByID(ctx, split[0]) - require.NoError(t, err, "fetch login key") - - err = api.Database.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{ - ID: apiKey.ID, - LastUsed: apiKey.LastUsed, - IPAddress: apiKey.IPAddress, - // This should cause a refresh - ExpiresAt: apiKey.ExpiresAt.Add(time.Hour * -2), - OAuthAccessToken: apiKey.OAuthAccessToken, - OAuthRefreshToken: apiKey.OAuthRefreshToken, - OAuthExpiry: apiKey.OAuthExpiry, - }) - require.NoError(t, err, "update login key") - - _, err = client.User(ctx, codersdk.Me) - require.NoError(t, err, "fetch user") - - apiKey, err = api.Database.GetAPIKeyByID(ctx, split[0]) - require.NoError(t, err, "fetch refreshed login key") - // 1 minute tolerance - require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*24).Add(time.Minute*-1)), "login key lasts 24 hrs") + require.True(t, apiKey.ExpiresAt.After(key.ExpiresAt.Add(time.Hour)), "api key should be longer expires") + require.Greater(t, apiKey.LifetimeSeconds, key.LifetimeSeconds, "api key should have longer lifetime") }) } @@ -237,11 +164,11 @@ func TestPostLogout(t *testing.T) { t.Parallel() ctx := context.Background() - client, api := coderdtest.NewWithAPI(t, nil) - _ = coderdtest.CreateFirstUser(t, client) + client := coderdtest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) keyID := strings.Split(client.SessionToken, "-")[0] - apiKey, err := api.Database.GetAPIKeyByID(ctx, keyID) + apiKey, err := client.GetAPIKey(ctx, admin.UserID.String(), keyID) require.NoError(t, err) require.Equal(t, keyID, apiKey.ID, "API key should exist in the database") @@ -259,44 +186,10 @@ func TestPostLogout(t *testing.T) { require.Equal(t, httpmw.SessionTokenKey, cookies[0].Name, "Cookie should be the auth cookie") require.Equal(t, -1, cookies[0].MaxAge, "Cookie should be set to delete") - apiKey, err = api.Database.GetAPIKeyByID(ctx, keyID) - require.ErrorIs(t, err, sql.ErrNoRows, "API key should not exist in the database") - }) - - t.Run("LogoutWithoutKey", func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - client, api := coderdtest.NewWithAPI(t, nil) - _ = coderdtest.CreateFirstUser(t, client) - keyID := strings.Split(client.SessionToken, "-")[0] - - apiKey, err := api.Database.GetAPIKeyByID(ctx, keyID) - require.NoError(t, err) - require.Equal(t, keyID, apiKey.ID, "API key should exist in the database") - - // Setting a fake database without the API Key to be used by the API. - // The middleware that extracts the API key is already set to read - // from the original database. - dbWithoutKey := databasefake.New() - api.Database = dbWithoutKey - - fullURL, err := client.URL.Parse("/api/v2/users/logout") - require.NoError(t, err, "Server URL should parse successfully") - - res, err := client.Request(ctx, http.MethodPost, fullURL.String(), nil) - require.NoError(t, err, "/logout request should succeed") - res.Body.Close() - require.Equal(t, http.StatusInternalServerError, res.StatusCode) - - cookies := res.Cookies() - require.Len(t, cookies, 1, "Exactly one cookie should be returned") - - require.Equal(t, httpmw.SessionTokenKey, cookies[0].Name, "Cookie should be the auth cookie") - require.Equal(t, -1, cookies[0].MaxAge, "Cookie should be set to delete") - - apiKey, err = api.Database.GetAPIKeyByID(ctx, keyID) - require.ErrorIs(t, err, sql.ErrNoRows, "API key should not exist in the database") + _, err = client.GetAPIKey(ctx, admin.UserID.String(), keyID) + var sdkErr = &codersdk.Error{} + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode(), "Expecting 401") }) } diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index c658791f93941..049001136577b 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -27,9 +27,10 @@ func TestWorkspaceAgent(t *testing.T) { t.Parallel() t.Run("Connect", func(t *testing.T) { t.Parallel() - client, coderAPI := coderdtest.NewWithAPI(t, nil) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerD: true, + }) user := coderdtest.CreateFirstUser(t, client) - daemonCloser := coderdtest.NewProvisionerDaemon(t, coderAPI) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -56,7 +57,6 @@ func TestWorkspaceAgent(t *testing.T) { coderdtest.AwaitTemplateVersionJob(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - daemonCloser.Close() resources, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID) require.NoError(t, err) @@ -72,9 +72,10 @@ func TestWorkspaceAgentListen(t *testing.T) { t.Run("Connect", func(t *testing.T) { t.Parallel() - client, coderAPI := coderdtest.NewWithAPI(t, nil) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerD: true, + }) user := coderdtest.CreateFirstUser(t, client) - daemonCloser := coderdtest.NewProvisionerDaemon(t, coderAPI) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -100,7 +101,6 @@ func TestWorkspaceAgentListen(t *testing.T) { coderdtest.AwaitTemplateVersionJob(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - daemonCloser.Close() agentClient := codersdk.New(client.URL) agentClient.SessionToken = authToken @@ -124,11 +124,11 @@ func TestWorkspaceAgentListen(t *testing.T) { t.Parallel() ctx := context.Background() - client, coderAPI := coderdtest.NewWithAPI(t, nil) - user := coderdtest.CreateFirstUser(t, client) - daemonCloser := coderdtest.NewProvisionerDaemon(t, coderAPI) - defer daemonCloser.Close() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerD: true, + }) + user := coderdtest.CreateFirstUser(t, client) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -196,9 +196,11 @@ func TestWorkspaceAgentListen(t *testing.T) { func TestWorkspaceAgentTURN(t *testing.T) { t.Parallel() - client, coderAPI := coderdtest.NewWithAPI(t, nil) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerD: true, + }) + user := coderdtest.CreateFirstUser(t, client) - daemonCloser := coderdtest.NewProvisionerDaemon(t, coderAPI) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -224,7 +226,6 @@ func TestWorkspaceAgentTURN(t *testing.T) { coderdtest.AwaitTemplateVersionJob(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - daemonCloser.Close() agentClient := codersdk.New(client.URL) agentClient.SessionToken = authToken @@ -257,9 +258,10 @@ func TestWorkspaceAgentPTY(t *testing.T) { // it seems like it could be either. t.Skip("ConPTY appears to be inconsistent on Windows.") } - client, coderAPI := coderdtest.NewWithAPI(t, nil) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerD: true, + }) user := coderdtest.CreateFirstUser(t, client) - daemonCloser := coderdtest.NewProvisionerDaemon(t, coderAPI) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -285,7 +287,6 @@ func TestWorkspaceAgentPTY(t *testing.T) { coderdtest.AwaitTemplateVersionJob(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - daemonCloser.Close() agentClient := codersdk.New(client.URL) agentClient.SessionToken = authToken diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 399b1874dc6aa..6c875ed052cd7 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -36,9 +36,10 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { go server.Serve(ln) tcpAddr, _ := ln.Addr().(*net.TCPAddr) - client, coderAPI := coderdtest.NewWithAPI(t, nil) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerD: true, + }) user := coderdtest.CreateFirstUser(t, client) - coderdtest.NewProvisionerDaemon(t, coderAPI) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 1893e768d6e70..9ee40a6460525 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -237,12 +237,12 @@ func TestWorkspaceBuildResources(t *testing.T) { t.Parallel() t.Run("ListRunning", func(t *testing.T) { t.Parallel() - client, coderAPI := coderdtest.NewWithAPI(t, nil) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerD: true, + }) user := coderdtest.CreateFirstUser(t, client) - closeDaemon := coderdtest.NewProvisionerDaemon(t, coderAPI) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - closeDaemon.Close() template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) _, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID) diff --git a/coderd/workspaceresources_test.go b/coderd/workspaceresources_test.go index a1a941f355386..7ecc96f50f3b2 100644 --- a/coderd/workspaceresources_test.go +++ b/coderd/workspaceresources_test.go @@ -46,9 +46,10 @@ func TestWorkspaceResource(t *testing.T) { t.Run("Apps", func(t *testing.T) { t.Parallel() - client, coderd := coderdtest.NewWithAPI(t, nil) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerD: true, + }) user := coderdtest.CreateFirstUser(t, client) - coderdtest.NewProvisionerDaemon(t, coderd) app := &proto.App{ Name: "code-server", Command: "some-command", diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 9f60bf816339d..fe5fb02ca3b8d 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -656,14 +656,15 @@ func TestPostWorkspaceBuild(t *testing.T) { t.Run("AlreadyActive", func(t *testing.T) { t.Parallel() - client, coderAPI := coderdtest.NewWithAPI(t, nil) + client, closer := coderdtest.NewWithProvisionerCloser(t, nil) + defer closer.Close() + user := coderdtest.CreateFirstUser(t, client) - closeDaemon := coderdtest.NewProvisionerDaemon(t, coderAPI) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + closer.Close() // Close here so workspace build doesn't process! - closeDaemon.Close() workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) _, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: template.ActiveVersionID, @@ -694,15 +695,15 @@ func TestPostWorkspaceBuild(t *testing.T) { t.Run("WithState", func(t *testing.T) { t.Parallel() - client, coderAPI := coderdtest.NewWithAPI(t, nil) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerD: true, + }) user := coderdtest.CreateFirstUser(t, client) - closeDaemon := coderdtest.NewProvisionerDaemon(t, coderAPI) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - _ = closeDaemon.Close() wantState := []byte("something") build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: template.ActiveVersionID, diff --git a/codersdk/users.go b/codersdk/users.go index 7e8c607661ec1..7396cd9a97d3f 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -22,6 +22,13 @@ const ( UserStatusSuspended UserStatus = "suspended" ) +type LoginType string + +const ( + LoginTypePassword LoginType = "password" + LoginTypeGithub LoginType = "github" +) + type UsersRequest struct { Search string `json:"search,omitempty" typescript:"-"` // Filter users by status. @@ -44,6 +51,17 @@ type User struct { Roles []Role `json:"roles"` } +type APIKey struct { + ID string `json:"id" validate:"required"` + UserID uuid.UUID `json:"user_id" validate:"required"` + LastUsed time.Time `json:"last_used" validate:"required"` + ExpiresAt time.Time `json:"expires_at" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` + LoginType LoginType `json:"login_type" validate:"required"` + LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` +} + type CreateFirstUserRequest struct { Email string `json:"email" validate:"required,email"` Username string `json:"username" validate:"required,username"` @@ -314,6 +332,19 @@ func (c *Client) CreateAPIKey(ctx context.Context, user string) (*GenerateAPIKey return apiKey, json.NewDecoder(res.Body).Decode(apiKey) } +func (c *Client) GetAPIKey(ctx context.Context, user string, id string) (*APIKey, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/%s", user, id), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode > http.StatusCreated { + return nil, readBodyAsError(res) + } + apiKey := &APIKey{} + return apiKey, json.NewDecoder(res.Body).Decode(apiKey) +} + // LoginWithPassword creates a session token authenticating with an email and password. // Call `SetSessionToken()` to apply the newly acquired token to the client. func (c *Client) LoginWithPassword(ctx context.Context, req LoginWithPasswordRequest) (LoginWithPasswordResponse, error) { diff --git a/provisioner/echo/serve.go b/provisioner/echo/serve.go index 70c10b548b4ce..934c715211675 100644 --- a/provisioner/echo/serve.go +++ b/provisioner/echo/serve.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "path/filepath" + "strings" "golang.org/x/xerrors" protobuf "google.golang.org/protobuf/proto" @@ -16,6 +17,25 @@ import ( "github.com/coder/coder/provisionersdk/proto" ) +const ( + ParameterExecKey = "echo.exec" + + errorKey = "error" + successKey = "success" +) + +func ParameterError(s string) string { + return formatExecValue(errorKey, s) +} + +func ParameterSucceed() string { + return formatExecValue(successKey, "") +} + +func formatExecValue(key, value string) string { + return fmt.Sprintf("%s=%s", key, value) +} + var ( // ParseComplete is a helper to indicate an empty parse completion. ParseComplete = []*proto.Parse_Response{{ @@ -29,6 +49,21 @@ var ( Complete: &proto.Provision_Complete{}, }, }} + + ParameterSuccess = []*proto.ParameterSchema{ + { + AllowOverrideSource: true, + Name: ParameterExecKey, + Description: "description 1", + DefaultSource: &proto.ParameterSource{ + Scheme: proto.ParameterSource_DATA, + Value: formatExecValue(successKey, ""), + }, + DefaultDestination: &proto.ParameterDestination{ + Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE, + }, + }, + } ) // Serve starts the echo provisioner. @@ -86,6 +121,23 @@ func (e *echo) Provision(stream proto.DRPCProvisioner_ProvisionStream) error { // A cancel could occur here! return nil } + + for _, param := range request.ParameterValues { + if param.Name == ParameterExecKey { + toks := strings.Split(param.Value, "=") + if len(toks) < 2 { + break + } + + switch toks[0] { + case errorKey: + return xerrors.Errorf("returning error: %v", toks[1]) + default: + // Do nothing + } + } + } + for index := 0; ; index++ { extension := ".protobuf" if request.DryRun { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5721d1d50a93e..96afeb66799f7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1,5 +1,17 @@ // Code generated by 'make coder/scripts/apitypings/main.go'. DO NOT EDIT. +// From codersdk/users.go:54:6 +export interface APIKey { + readonly id: string + readonly user_id: string + readonly last_used: string + readonly expires_at: string + readonly created_at: string + readonly updated_at: string + readonly login_type: LoginType + readonly lifetime_seconds: number +} + // From codersdk/workspaceagents.go:36:6 export interface AWSInstanceIdentityToken { readonly signature: string @@ -12,7 +24,7 @@ export interface AgentGitSSHKey { readonly private_key: string } -// From codersdk/users.go:156:6 +// From codersdk/users.go:174:6 export interface AuthMethods { readonly password: boolean readonly github: boolean @@ -37,7 +49,7 @@ export interface ComputedParameter extends Parameter { readonly default_source_value: boolean } -// From codersdk/users.go:47:6 +// From codersdk/users.go:65:6 export interface CreateFirstUserRequest { readonly email: string readonly username: string @@ -45,13 +57,13 @@ export interface CreateFirstUserRequest { readonly organization: string } -// From codersdk/users.go:55:6 +// From codersdk/users.go:73:6 export interface CreateFirstUserResponse { readonly user_id: string readonly organization_id: string } -// From codersdk/users.go:151:6 +// From codersdk/users.go:169:6 export interface CreateOrganizationRequest { readonly name: string } @@ -90,7 +102,7 @@ export interface CreateTemplateVersionRequest { readonly parameter_values?: CreateParameterRequest[] } -// From codersdk/users.go:60:6 +// From codersdk/users.go:78:6 export interface CreateUserRequest { readonly email: string readonly username: string @@ -116,7 +128,7 @@ export interface CreateWorkspaceRequest { readonly parameter_values?: CreateParameterRequest[] } -// From codersdk/users.go:147:6 +// From codersdk/users.go:165:6 export interface GenerateAPIKeyResponse { readonly key: string } @@ -134,13 +146,13 @@ export interface GoogleInstanceIdentityToken { readonly json_web_token: string } -// From codersdk/users.go:136:6 +// From codersdk/users.go:154:6 export interface LoginWithPasswordRequest { readonly email: string readonly password: string } -// From codersdk/users.go:142:6 +// From codersdk/users.go:160:6 export interface LoginWithPasswordResponse { readonly session_token: string } @@ -283,7 +295,7 @@ export interface UpdateActiveTemplateVersion { readonly id: string } -// From codersdk/users.go:76:6 +// From codersdk/users.go:94:6 export interface UpdateRoles { readonly roles: string[] } @@ -295,13 +307,13 @@ export interface UpdateTemplateMeta { readonly min_autostart_interval_ms?: number } -// From codersdk/users.go:71:6 +// From codersdk/users.go:89:6 export interface UpdateUserPasswordRequest { readonly old_password: string readonly password: string } -// From codersdk/users.go:67:6 +// From codersdk/users.go:85:6 export interface UpdateUserProfileRequest { readonly username: string } @@ -321,7 +333,7 @@ export interface UploadResponse { readonly hash: string } -// From codersdk/users.go:37:6 +// From codersdk/users.go:44:6 export interface User { readonly id: string readonly email: string @@ -332,13 +344,13 @@ export interface User { readonly roles: Role[] } -// From codersdk/users.go:101:6 +// From codersdk/users.go:119:6 export interface UserAuthorization { readonly object: UserAuthorizationObject readonly action: string } -// From codersdk/users.go:117:6 +// From codersdk/users.go:135:6 export interface UserAuthorizationObject { readonly resource_type: string readonly owner_id?: string @@ -346,21 +358,21 @@ export interface UserAuthorizationObject { readonly resource_id?: string } -// From codersdk/users.go:90:6 +// From codersdk/users.go:108:6 export interface UserAuthorizationRequest { readonly checks: Record } -// From codersdk/users.go:85:6 +// From codersdk/users.go:103:6 export type UserAuthorizationResponse = Record -// From codersdk/users.go:80:6 +// From codersdk/users.go:98:6 export interface UserRoles { readonly roles: string[] readonly organization_roles: Record } -// From codersdk/users.go:25:6 +// From codersdk/users.go:32:6 export interface UsersRequest extends Pagination { readonly q?: string } @@ -500,6 +512,9 @@ export type LogLevel = "debug" | "error" | "info" | "trace" | "warn" // From codersdk/provisionerdaemons.go:21:6 export type LogSource = "provisioner" | "provisioner_daemon" +// From codersdk/users.go:25:6 +export type LoginType = "github" | "password" + // From codersdk/parameters.go:29:6 export type ParameterDestinationScheme = "environment_variable" | "none" | "provisioner_variable"