diff --git a/.vscode/settings.json b/.vscode/settings.json index 53c22854a612c..db290aedc5202 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,5 +22,6 @@ "cmd": "make gen" } ] - } + }, + "cSpell.words": ["coderd", "coderdtest", "codersdk", "httpmw", "oneof", "stretchr", "xerrors"] } diff --git a/coderd/coderd.go b/coderd/coderd.go index b6c01e3e30745..05569b8aeb5e1 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -16,18 +16,14 @@ import ( type Options struct { Logger slog.Logger Database database.Store + Pubsub database.Pubsub } // New constructs the Coder API into an HTTP handler. func New(options *Options) http.Handler { - projects := &projects{ - Database: options.Database, - } - users := &users{ - Database: options.Database, - } - workspaces := &workspaces{ + api := &api{ Database: options.Database, + Pubsub: options.Pubsub, } r := chi.NewRouter() @@ -37,38 +33,38 @@ func New(options *Options) http.Handler { Message: "👋", }) }) - r.Post("/login", users.loginWithPassword) - r.Post("/logout", users.logout) + r.Post("/login", api.postLogin) + r.Post("/logout", api.postLogout) // Used for setup. - r.Post("/user", users.createInitialUser) + r.Post("/user", api.postUser) r.Route("/users", func(r chi.Router) { r.Use( httpmw.ExtractAPIKey(options.Database, nil), ) - r.Post("/", users.createUser) + r.Post("/", api.postUsers) r.Group(func(r chi.Router) { r.Use(httpmw.ExtractUserParam(options.Database)) - r.Get("/{user}", users.user) - r.Get("/{user}/organizations", users.userOrganizations) + r.Get("/{user}", api.userByName) + r.Get("/{user}/organizations", api.organizationsByUser) }) }) r.Route("/projects", func(r chi.Router) { r.Use( httpmw.ExtractAPIKey(options.Database, nil), ) - r.Get("/", projects.allProjects) + r.Get("/", api.projects) r.Route("/{organization}", func(r chi.Router) { r.Use(httpmw.ExtractOrganizationParam(options.Database)) - r.Get("/", projects.allProjectsForOrganization) - r.Post("/", projects.createProject) + r.Get("/", api.projectsByOrganization) + r.Post("/", api.postProjectsByOrganization) r.Route("/{project}", func(r chi.Router) { r.Use(httpmw.ExtractProjectParam(options.Database)) - r.Get("/", projects.project) + r.Get("/", api.projectByOrganization) + r.Get("/workspaces", api.workspacesByProject) r.Route("/history", func(r chi.Router) { - r.Get("/", projects.allProjectHistory) - r.Post("/", projects.createProjectHistory) + r.Get("/", api.projectHistoryByOrganization) + r.Post("/", api.postProjectHistoryByOrganization) }) - r.Get("/workspaces", workspaces.allWorkspacesForProject) }) }) }) @@ -77,18 +73,18 @@ func New(options *Options) http.Handler { // their respective routes. eg. /orgs//workspaces r.Route("/workspaces", func(r chi.Router) { r.Use(httpmw.ExtractAPIKey(options.Database, nil)) - r.Get("/", workspaces.listAllWorkspaces) + r.Get("/", api.workspaces) r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractUserParam(options.Database)) - r.Get("/", workspaces.listAllWorkspaces) - r.Post("/", workspaces.createWorkspaceForUser) + r.Get("/", api.workspaces) + r.Post("/", api.postWorkspaceByUser) r.Route("/{workspace}", func(r chi.Router) { r.Use(httpmw.ExtractWorkspaceParam(options.Database)) - r.Get("/", workspaces.singleWorkspace) + r.Get("/", api.workspaceByUser) r.Route("/history", func(r chi.Router) { - r.Post("/", workspaces.createWorkspaceHistory) - r.Get("/", workspaces.listAllWorkspaceHistory) - r.Get("/latest", workspaces.latestWorkspaceHistory) + r.Post("/", api.postWorkspaceHistoryByUser) + r.Get("/", api.workspaceHistoryByUser) + r.Get("/latest", api.latestWorkspaceHistoryByUser) }) }) }) @@ -97,3 +93,10 @@ func New(options *Options) http.Handler { r.NotFound(site.Handler().ServeHTTP) return r } + +// API contains all route handlers. Only HTTP handlers should +// be added to this struct for code clarity. +type api struct { + Database database.Store + Pubsub database.Pubsub +} diff --git a/coderd/projecthistory.go b/coderd/projecthistory.go new file mode 100644 index 0000000000000..a5057b8c514f0 --- /dev/null +++ b/coderd/projecthistory.go @@ -0,0 +1,118 @@ +package coderd + +import ( + "archive/tar" + "bytes" + "database/sql" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-chi/render" + "github.com/google/uuid" + "github.com/moby/moby/pkg/namesgenerator" + + "github.com/coder/coder/database" + "github.com/coder/coder/httpapi" + "github.com/coder/coder/httpmw" +) + +// ProjectHistory is the JSON representation of Coder project version history. +type ProjectHistory struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + StorageMethod database.ProjectStorageMethod `json:"storage_method"` +} + +// CreateProjectHistoryRequest enables callers to create a new Project Version. +type CreateProjectHistoryRequest struct { + StorageMethod database.ProjectStorageMethod `json:"storage_method" validate:"oneof=inline-archive,required"` + StorageSource []byte `json:"storage_source" validate:"max=1048576,required"` +} + +// Lists history for a single project. +func (api *api) projectHistoryByOrganization(rw http.ResponseWriter, r *http.Request) { + project := httpmw.ProjectParam(r) + + history, err := api.Database.GetProjectHistoryByProjectID(r.Context(), project.ID) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get project history: %s", err), + }) + return + } + apiHistory := make([]ProjectHistory, 0) + for _, version := range history { + apiHistory = append(apiHistory, convertProjectHistory(version)) + } + render.Status(r, http.StatusOK) + render.JSON(rw, r, apiHistory) +} + +// Creates a new version of the project. An import job is queued to parse +// the storage method provided. Once completed, the import job will specify +// the version as latest. +func (api *api) postProjectHistoryByOrganization(rw http.ResponseWriter, r *http.Request) { + var createProjectVersion CreateProjectHistoryRequest + if !httpapi.Read(rw, r, &createProjectVersion) { + return + } + + switch createProjectVersion.StorageMethod { + case database.ProjectStorageMethodInlineArchive: + tarReader := tar.NewReader(bytes.NewReader(createProjectVersion.StorageSource)) + _, err := tarReader.Next() + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: "the archive must be a tar", + }) + return + } + default: + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("unsupported storage method %s", createProjectVersion.StorageMethod), + }) + return + } + + project := httpmw.ProjectParam(r) + history, err := api.Database.InsertProjectHistory(r.Context(), database.InsertProjectHistoryParams{ + ID: uuid.New(), + ProjectID: project.ID, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + Name: namesgenerator.GetRandomName(1), + StorageMethod: createProjectVersion.StorageMethod, + StorageSource: createProjectVersion.StorageSource, + // TODO: Make this do something! + ImportJobID: uuid.New(), + }) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("insert project history: %s", err), + }) + return + } + + // TODO: A job to process the new version should occur here. + + render.Status(r, http.StatusCreated) + render.JSON(rw, r, convertProjectHistory(history)) +} + +func convertProjectHistory(history database.ProjectHistory) ProjectHistory { + return ProjectHistory{ + ID: history.ID, + ProjectID: history.ProjectID, + CreatedAt: history.CreatedAt, + UpdatedAt: history.UpdatedAt, + Name: history.Name, + } +} diff --git a/coderd/projecthistory_test.go b/coderd/projecthistory_test.go new file mode 100644 index 0000000000000..4c9b727fbe358 --- /dev/null +++ b/coderd/projecthistory_test.go @@ -0,0 +1,101 @@ +package coderd_test + +import ( + "archive/tar" + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/database" +) + +func TestProjectHistory(t *testing.T) { + t.Parallel() + + t.Run("NoHistory", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ + Name: "someproject", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + versions, err := server.Client.ProjectHistory(context.Background(), user.Organization, project.Name) + require.NoError(t, err) + require.Len(t, versions, 0) + }) + + t.Run("CreateHistory", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ + Name: "someproject", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + var buffer bytes.Buffer + writer := tar.NewWriter(&buffer) + err = writer.WriteHeader(&tar.Header{ + Name: "file", + Size: 1 << 10, + }) + require.NoError(t, err) + _, err = writer.Write(make([]byte, 1<<10)) + require.NoError(t, err) + _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectHistoryRequest{ + StorageMethod: database.ProjectStorageMethodInlineArchive, + StorageSource: buffer.Bytes(), + }) + require.NoError(t, err) + versions, err := server.Client.ProjectHistory(context.Background(), user.Organization, project.Name) + require.NoError(t, err) + require.Len(t, versions, 1) + }) + + t.Run("CreateHistoryArchiveTooBig", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ + Name: "someproject", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + var buffer bytes.Buffer + writer := tar.NewWriter(&buffer) + err = writer.WriteHeader(&tar.Header{ + Name: "file", + Size: 1 << 21, + }) + require.NoError(t, err) + _, err = writer.Write(make([]byte, 1<<21)) + require.NoError(t, err) + _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectHistoryRequest{ + StorageMethod: database.ProjectStorageMethodInlineArchive, + StorageSource: buffer.Bytes(), + }) + require.Error(t, err) + }) + + t.Run("CreateHistoryInvalidArchive", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ + Name: "someproject", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectHistoryRequest{ + StorageMethod: database.ProjectStorageMethodInlineArchive, + StorageSource: []byte{}, + }) + require.Error(t, err) + }) +} diff --git a/coderd/projects.go b/coderd/projects.go index 5ef2ea5067b6a..b7e161adcd7bc 100644 --- a/coderd/projects.go +++ b/coderd/projects.go @@ -1,19 +1,14 @@ package coderd import ( - "archive/tar" - "bytes" "database/sql" "errors" "fmt" "net/http" - "time" "github.com/go-chi/render" "github.com/google/uuid" - "github.com/moby/moby/pkg/namesgenerator" - "github.com/coder/coder/database" "github.com/coder/coder/httpapi" "github.com/coder/coder/httpmw" @@ -24,36 +19,16 @@ import ( // abstracted for ease of change later on. type Project database.Project -// ProjectHistory is the JSON representation of Coder project version history. -type ProjectHistory struct { - ID uuid.UUID `json:"id"` - ProjectID uuid.UUID `json:"project_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Name string `json:"name"` - StorageMethod database.ProjectStorageMethod `json:"storage_method"` -} - // CreateProjectRequest enables callers to create a new Project. type CreateProjectRequest struct { Name string `json:"name" validate:"username,required"` Provisioner database.ProvisionerType `json:"provisioner" validate:"oneof=terraform cdr-basic,required"` } -// CreateProjectVersionRequest enables callers to create a new Project Version. -type CreateProjectVersionRequest struct { - StorageMethod database.ProjectStorageMethod `json:"storage_method" validate:"oneof=inline-archive,required"` - StorageSource []byte `json:"storage_source" validate:"max=1048576,required"` -} - -type projects struct { - Database database.Store -} - // Lists all projects the authenticated user has access to. -func (p *projects) allProjects(rw http.ResponseWriter, r *http.Request) { +func (api *api) projects(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) - organizations, err := p.Database.GetOrganizationsByUserID(r.Context(), apiKey.UserID) + organizations, err := api.Database.GetOrganizationsByUserID(r.Context(), apiKey.UserID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get organizations: %s", err.Error()), @@ -64,7 +39,7 @@ func (p *projects) allProjects(rw http.ResponseWriter, r *http.Request) { for _, organization := range organizations { organizationIDs = append(organizationIDs, organization.ID) } - projects, err := p.Database.GetProjectsByOrganizationIDs(r.Context(), organizationIDs) + projects, err := api.Database.GetProjectsByOrganizationIDs(r.Context(), organizationIDs) if errors.Is(err, sql.ErrNoRows) { err = nil } @@ -79,9 +54,9 @@ func (p *projects) allProjects(rw http.ResponseWriter, r *http.Request) { } // Lists all projects in an organization. -func (p *projects) allProjectsForOrganization(rw http.ResponseWriter, r *http.Request) { +func (api *api) projectsByOrganization(rw http.ResponseWriter, r *http.Request) { organization := httpmw.OrganizationParam(r) - projects, err := p.Database.GetProjectsByOrganizationIDs(r.Context(), []string{organization.ID}) + projects, err := api.Database.GetProjectsByOrganizationIDs(r.Context(), []string{organization.ID}) if errors.Is(err, sql.ErrNoRows) { err = nil } @@ -96,13 +71,13 @@ func (p *projects) allProjectsForOrganization(rw http.ResponseWriter, r *http.Re } // Creates a new project in an organization. -func (p *projects) createProject(rw http.ResponseWriter, r *http.Request) { +func (api *api) postProjectsByOrganization(rw http.ResponseWriter, r *http.Request) { var createProject CreateProjectRequest if !httpapi.Read(rw, r, &createProject) { return } organization := httpmw.OrganizationParam(r) - _, err := p.Database.GetProjectByOrganizationAndName(r.Context(), database.GetProjectByOrganizationAndNameParams{ + _, err := api.Database.GetProjectByOrganizationAndName(r.Context(), database.GetProjectByOrganizationAndNameParams{ OrganizationID: organization.ID, Name: createProject.Name, }) @@ -123,7 +98,7 @@ func (p *projects) createProject(rw http.ResponseWriter, r *http.Request) { return } - project, err := p.Database.InsertProject(r.Context(), database.InsertProjectParams{ + project, err := api.Database.InsertProject(r.Context(), database.InsertProjectParams{ ID: uuid.New(), CreatedAt: database.Now(), UpdatedAt: database.Now(), @@ -142,92 +117,35 @@ func (p *projects) createProject(rw http.ResponseWriter, r *http.Request) { } // Returns a single project. -func (*projects) project(rw http.ResponseWriter, r *http.Request) { +func (*api) projectByOrganization(rw http.ResponseWriter, r *http.Request) { project := httpmw.ProjectParam(r) render.Status(r, http.StatusOK) render.JSON(rw, r, project) } -// Lists history for a single project. -func (p *projects) allProjectHistory(rw http.ResponseWriter, r *http.Request) { +// Returns all workspaces for a specific project. +func (api *api) workspacesByProject(rw http.ResponseWriter, r *http.Request) { + apiKey := httpmw.APIKey(r) project := httpmw.ProjectParam(r) - - history, err := p.Database.GetProjectHistoryByProjectID(r.Context(), project.ID) + workspaces, err := api.Database.GetWorkspacesByProjectAndUserID(r.Context(), database.GetWorkspacesByProjectAndUserIDParams{ + OwnerID: apiKey.UserID, + ProjectID: project.ID, + }) if errors.Is(err, sql.ErrNoRows) { err = nil } if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get project history: %s", err), + Message: fmt.Sprintf("get workspaces: %s", err), }) return } - apiHistory := make([]ProjectHistory, 0) - for _, version := range history { - apiHistory = append(apiHistory, convertProjectHistory(version)) - } - render.Status(r, http.StatusOK) - render.JSON(rw, r, apiHistory) -} -// Creates a new version of the project. An import job is queued to parse -// the storage method provided. Once completed, the import job will specify -// the version as latest. -func (p *projects) createProjectHistory(rw http.ResponseWriter, r *http.Request) { - var createProjectVersion CreateProjectVersionRequest - if !httpapi.Read(rw, r, &createProjectVersion) { - return - } - - switch createProjectVersion.StorageMethod { - case database.ProjectStorageMethodInlineArchive: - tarReader := tar.NewReader(bytes.NewReader(createProjectVersion.StorageSource)) - _, err := tarReader.Next() - if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: "the archive must be a tar", - }) - return - } - default: - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("unsupported storage method %s", createProjectVersion.StorageMethod), - }) - return - } - - project := httpmw.ProjectParam(r) - history, err := p.Database.InsertProjectHistory(r.Context(), database.InsertProjectHistoryParams{ - ID: uuid.New(), - ProjectID: project.ID, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - Name: namesgenerator.GetRandomName(1), - StorageMethod: createProjectVersion.StorageMethod, - StorageSource: createProjectVersion.StorageSource, - // TODO: Make this do something! - ImportJobID: uuid.New(), - }) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("insert project history: %s", err), - }) - return - } - - // TODO: A job to process the new version should occur here. - - render.Status(r, http.StatusCreated) - render.JSON(rw, r, convertProjectHistory(history)) -} - -func convertProjectHistory(history database.ProjectHistory) ProjectHistory { - return ProjectHistory{ - ID: history.ID, - ProjectID: history.ProjectID, - CreatedAt: history.CreatedAt, - UpdatedAt: history.UpdatedAt, - Name: history.Name, + apiWorkspaces := make([]Workspace, 0, len(workspaces)) + for _, workspace := range workspaces { + apiWorkspaces = append(apiWorkspaces, convertWorkspace(workspace)) } + render.Status(r, http.StatusOK) + render.JSON(rw, r, apiWorkspaces) } diff --git a/coderd/projects_test.go b/coderd/projects_test.go index cd02703f64b6e..98261ba105cf7 100644 --- a/coderd/projects_test.go +++ b/coderd/projects_test.go @@ -1,8 +1,6 @@ package coderd_test import ( - "archive/tar" - "bytes" "context" "testing" @@ -94,87 +92,4 @@ func TestProjects(t *testing.T) { _, err = server.Client.Project(context.Background(), user.Organization, project.Name) require.NoError(t, err) }) - - t.Run("NoVersions", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ - Name: "someproject", - Provisioner: database.ProvisionerTypeTerraform, - }) - require.NoError(t, err) - versions, err := server.Client.ProjectHistory(context.Background(), user.Organization, project.Name) - require.NoError(t, err) - require.Len(t, versions, 0) - }) - - t.Run("CreateVersion", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ - Name: "someproject", - Provisioner: database.ProvisionerTypeTerraform, - }) - require.NoError(t, err) - var buffer bytes.Buffer - writer := tar.NewWriter(&buffer) - err = writer.WriteHeader(&tar.Header{ - Name: "file", - Size: 1 << 10, - }) - require.NoError(t, err) - _, err = writer.Write(make([]byte, 1<<10)) - require.NoError(t, err) - _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{ - StorageMethod: database.ProjectStorageMethodInlineArchive, - StorageSource: buffer.Bytes(), - }) - require.NoError(t, err) - versions, err := server.Client.ProjectHistory(context.Background(), user.Organization, project.Name) - require.NoError(t, err) - require.Len(t, versions, 1) - }) - - t.Run("CreateVersionArchiveTooBig", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ - Name: "someproject", - Provisioner: database.ProvisionerTypeTerraform, - }) - require.NoError(t, err) - var buffer bytes.Buffer - writer := tar.NewWriter(&buffer) - err = writer.WriteHeader(&tar.Header{ - Name: "file", - Size: 1 << 21, - }) - require.NoError(t, err) - _, err = writer.Write(make([]byte, 1<<21)) - require.NoError(t, err) - _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{ - StorageMethod: database.ProjectStorageMethodInlineArchive, - StorageSource: buffer.Bytes(), - }) - require.Error(t, err) - }) - - t.Run("CreateVersionInvalidArchive", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ - Name: "someproject", - Provisioner: database.ProvisionerTypeTerraform, - }) - require.NoError(t, err) - _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{ - StorageMethod: database.ProjectStorageMethodInlineArchive, - StorageSource: []byte{}, - }) - require.Error(t, err) - }) } diff --git a/coderd/users.go b/coderd/users.go index 6fe812d9decdc..8aafc1f1bd9e0 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -55,18 +55,14 @@ type LoginWithPasswordResponse struct { SessionToken string `json:"session_token" validate:"required"` } -type users struct { - Database database.Store -} - // Creates the initial user for a Coder deployment. -func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) { +func (api *api) postUser(rw http.ResponseWriter, r *http.Request) { var createUser CreateInitialUserRequest if !httpapi.Read(rw, r, &createUser) { return } // This should only function for the first user. - userCount, err := users.Database.GetUserCount(r.Context()) + userCount, err := api.Database.GetUserCount(r.Context()) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get user count: %s", err.Error()), @@ -90,8 +86,8 @@ func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) { // Create the user, organization, and membership to the user. var user database.User - err = users.Database.InTx(func(s database.Store) error { - user, err = users.Database.InsertUser(r.Context(), database.InsertUserParams{ + err = api.Database.InTx(func(s database.Store) error { + user, err = api.Database.InsertUser(r.Context(), database.InsertUserParams{ ID: uuid.NewString(), Email: createUser.Email, HashedPassword: []byte(hashedPassword), @@ -103,7 +99,7 @@ func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) { if err != nil { return xerrors.Errorf("create user: %w", err) } - organization, err := users.Database.InsertOrganization(r.Context(), database.InsertOrganizationParams{ + organization, err := api.Database.InsertOrganization(r.Context(), database.InsertOrganizationParams{ ID: uuid.NewString(), Name: createUser.Organization, CreatedAt: database.Now(), @@ -112,7 +108,7 @@ func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) { if err != nil { return xerrors.Errorf("create organization: %w", err) } - _, err = users.Database.InsertOrganizationMember(r.Context(), database.InsertOrganizationMemberParams{ + _, err = api.Database.InsertOrganizationMember(r.Context(), database.InsertOrganizationMemberParams{ OrganizationID: organization.ID, UserID: user.ID, CreatedAt: database.Now(), @@ -136,12 +132,12 @@ func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) { } // Creates a new user. -func (users *users) createUser(rw http.ResponseWriter, r *http.Request) { +func (api *api) postUsers(rw http.ResponseWriter, r *http.Request) { var createUser CreateUserRequest if !httpapi.Read(rw, r, &createUser) { return } - _, err := users.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ + _, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ Username: createUser.Username, Email: createUser.Email, }) @@ -166,7 +162,7 @@ func (users *users) createUser(rw http.ResponseWriter, r *http.Request) { return } - user, err := users.Database.InsertUser(r.Context(), database.InsertUserParams{ + user, err := api.Database.InsertUser(r.Context(), database.InsertUserParams{ ID: uuid.NewString(), Email: createUser.Email, HashedPassword: []byte(hashedPassword), @@ -188,17 +184,17 @@ func (users *users) createUser(rw http.ResponseWriter, r *http.Request) { // Returns the parameterized user requested. All validation // is completed in the middleware for this route. -func (*users) user(rw http.ResponseWriter, r *http.Request) { +func (*api) userByName(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) render.JSON(rw, r, convertUser(user)) } // Returns organizations the parameterized user has access to. -func (users *users) userOrganizations(rw http.ResponseWriter, r *http.Request) { +func (api *api) organizationsByUser(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) - organizations, err := users.Database.GetOrganizationsByUserID(r.Context(), user.ID) + organizations, err := api.Database.GetOrganizationsByUserID(r.Context(), user.ID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get organizations: %s", err.Error()), @@ -216,12 +212,12 @@ func (users *users) userOrganizations(rw http.ResponseWriter, r *http.Request) { } // Authenticates the user with an email and password. -func (users *users) loginWithPassword(rw http.ResponseWriter, r *http.Request) { +func (api *api) postLogin(rw http.ResponseWriter, r *http.Request) { var loginWithPassword LoginWithPasswordRequest if !httpapi.Read(rw, r, &loginWithPassword) { return } - user, err := users.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ + user, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ Email: loginWithPassword.Email, }) if errors.Is(err, sql.ErrNoRows) { @@ -260,7 +256,7 @@ func (users *users) loginWithPassword(rw http.ResponseWriter, r *http.Request) { } hashed := sha256.Sum256([]byte(keySecret)) - _, err = users.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ + _, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ ID: keyID, UserID: user.ID, ExpiresAt: database.Now().Add(24 * time.Hour), @@ -293,7 +289,7 @@ func (users *users) loginWithPassword(rw http.ResponseWriter, r *http.Request) { } // Clear the user's session cookie -func (*users) logout(rw http.ResponseWriter, r *http.Request) { +func (*api) postLogout(rw http.ResponseWriter, r *http.Request) { // Get a blank token cookie cookie := &http.Cookie{ // MaxAge < 0 means to delete the cookie now diff --git a/coderd/workspacehistory.go b/coderd/workspacehistory.go new file mode 100644 index 0000000000000..32eba2e98e2da --- /dev/null +++ b/coderd/workspacehistory.go @@ -0,0 +1,182 @@ +package coderd + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-chi/render" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/database" + "github.com/coder/coder/httpapi" + "github.com/coder/coder/httpmw" +) + +// WorkspaceHistory is an at-point representation of a workspace state. +// Iterate on before/after to determine a chronological history. +type WorkspaceHistory struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CompletedAt time.Time `json:"completed_at"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ProjectHistoryID uuid.UUID `json:"project_history_id"` + BeforeID uuid.UUID `json:"before_id"` + AfterID uuid.UUID `json:"after_id"` + Transition database.WorkspaceTransition `json:"transition"` + Initiator string `json:"initiator"` +} + +// CreateWorkspaceHistoryRequest provides options to update the latest workspace history. +type CreateWorkspaceHistoryRequest struct { + ProjectHistoryID uuid.UUID `json:"project_history_id" validate:"required"` + Transition database.WorkspaceTransition `json:"transition" validate:"oneof=create start stop delete,required"` +} + +// Begins transitioning a workspace to new state. This queues a provision job to asynchronously +// update the underlying infrastructure. Only one historical transition can occur at a time. +func (api *api) postWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Request) { + var createBuild CreateWorkspaceHistoryRequest + if !httpapi.Read(rw, r, &createBuild) { + return + } + user := httpmw.UserParam(r) + workspace := httpmw.WorkspaceParam(r) + projectHistory, err := api.Database.GetProjectHistoryByID(r.Context(), createBuild.ProjectHistoryID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: "project history not found", + Errors: []httpapi.Error{{ + Field: "project_history_id", + Code: "exists", + }}, + }) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get project history: %s", err), + }) + return + } + + // Store prior history ID if it exists to update it after we create new! + priorHistoryID := uuid.NullUUID{} + priorHistory, err := api.Database.GetWorkspaceHistoryByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) + if err == nil { + if !priorHistory.CompletedAt.Valid { + httpapi.Write(rw, http.StatusConflict, httpapi.Response{ + Message: "a workspace build is already active", + }) + return + } + + priorHistoryID = uuid.NullUUID{ + UUID: priorHistory.ID, + Valid: true, + } + } + if !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get prior workspace history: %s", err), + }) + return + } + + var workspaceHistory database.WorkspaceHistory + // This must happen in a transaction to ensure history can be inserted, and + // the prior history can update it's "after" column to point at the new. + err = api.Database.InTx(func(db database.Store) error { + workspaceHistory, err = db.InsertWorkspaceHistory(r.Context(), database.InsertWorkspaceHistoryParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + WorkspaceID: workspace.ID, + ProjectHistoryID: projectHistory.ID, + BeforeID: priorHistoryID, + Initiator: user.ID, + Transition: createBuild.Transition, + // This should create a provision job once that gets implemented! + ProvisionJobID: uuid.New(), + }) + if err != nil { + return xerrors.Errorf("insert workspace history: %w", err) + } + + if priorHistoryID.Valid { + // Update the prior history entries "after" column. + err = db.UpdateWorkspaceHistoryByID(r.Context(), database.UpdateWorkspaceHistoryByIDParams{ + ID: priorHistory.ID, + UpdatedAt: database.Now(), + AfterID: uuid.NullUUID{ + UUID: workspaceHistory.ID, + Valid: true, + }, + }) + if err != nil { + return xerrors.Errorf("update prior workspace history: %w", err) + } + } + + return nil + }) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: err.Error(), + }) + return + } + + render.Status(r, http.StatusCreated) + render.JSON(rw, r, convertWorkspaceHistory(workspaceHistory)) +} + +// Returns all workspace history. This is not sorted. Use before/after to chronologically sort. +func (api *api) workspaceHistoryByUser(rw http.ResponseWriter, r *http.Request) { + workspace := httpmw.WorkspaceParam(r) + + histories, err := api.Database.GetWorkspaceHistoryByWorkspaceID(r.Context(), workspace.ID) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace history: %s", err), + }) + return + } + + apiHistory := make([]WorkspaceHistory, 0, len(histories)) + for _, history := range histories { + apiHistory = append(apiHistory, convertWorkspaceHistory(history)) + } + + render.Status(r, http.StatusOK) + render.JSON(rw, r, apiHistory) +} + +// Returns the latest workspace history. This works by querying for history without "after" set. +func (api *api) latestWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Request) { + workspace := httpmw.WorkspaceParam(r) + + history, err := api.Database.GetWorkspaceHistoryByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ + Message: "workspace has no history", + }) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace history: %s", err), + }) + return + } + + render.Status(r, http.StatusOK) + render.JSON(rw, r, convertWorkspaceHistory(history)) +} diff --git a/coderd/workspacehistory_test.go b/coderd/workspacehistory_test.go new file mode 100644 index 0000000000000..773de1a5b5a95 --- /dev/null +++ b/coderd/workspacehistory_test.go @@ -0,0 +1,135 @@ +package coderd_test + +import ( + "archive/tar" + "bytes" + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/database" +) + +func TestWorkspaceHistory(t *testing.T) { + t.Parallel() + + setupProjectAndWorkspace := func(t *testing.T, client *codersdk.Client, user coderd.CreateInitialUserRequest) (coderd.Project, coderd.Workspace) { + project, err := client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ + Name: "banana", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + workspace, err := client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{ + Name: "example", + ProjectID: project.ID, + }) + require.NoError(t, err) + return project, workspace + } + + setupProjectHistory := func(t *testing.T, client *codersdk.Client, user coderd.CreateInitialUserRequest, project coderd.Project) coderd.ProjectHistory { + var buffer bytes.Buffer + writer := tar.NewWriter(&buffer) + err := writer.WriteHeader(&tar.Header{ + Name: "file", + Size: 1 << 10, + }) + require.NoError(t, err) + _, err = writer.Write(make([]byte, 1<<10)) + require.NoError(t, err) + projectHistory, err := client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectHistoryRequest{ + StorageMethod: database.ProjectStorageMethodInlineArchive, + StorageSource: buffer.Bytes(), + }) + require.NoError(t, err) + return projectHistory + } + + t.Run("AllHistory", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, workspace := setupProjectAndWorkspace(t, server.Client, user) + history, err := server.Client.WorkspaceHistory(context.Background(), "", workspace.Name) + require.NoError(t, err) + require.Len(t, history, 0) + projectVersion := setupProjectHistory(t, server.Client, user, project) + _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ + ProjectHistoryID: projectVersion.ID, + Transition: database.WorkspaceTransitionCreate, + }) + require.NoError(t, err) + history, err = server.Client.WorkspaceHistory(context.Background(), "", workspace.Name) + require.NoError(t, err) + require.Len(t, history, 1) + }) + + t.Run("LatestHistory", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, workspace := setupProjectAndWorkspace(t, server.Client, user) + _, err := server.Client.LatestWorkspaceHistory(context.Background(), "", workspace.Name) + require.Error(t, err) + projectVersion := setupProjectHistory(t, server.Client, user, project) + _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ + ProjectHistoryID: projectVersion.ID, + Transition: database.WorkspaceTransitionCreate, + }) + require.NoError(t, err) + _, err = server.Client.LatestWorkspaceHistory(context.Background(), "", workspace.Name) + require.NoError(t, err) + }) + + t.Run("CreateHistory", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, workspace := setupProjectAndWorkspace(t, server.Client, user) + projectHistory := setupProjectHistory(t, server.Client, user, project) + + _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ + ProjectHistoryID: projectHistory.ID, + Transition: database.WorkspaceTransitionCreate, + }) + require.NoError(t, err) + }) + + t.Run("CreateHistoryAlreadyInProgress", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, workspace := setupProjectAndWorkspace(t, server.Client, user) + projectHistory := setupProjectHistory(t, server.Client, user, project) + + _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ + ProjectHistoryID: projectHistory.ID, + Transition: database.WorkspaceTransitionCreate, + }) + require.NoError(t, err) + + _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ + ProjectHistoryID: projectHistory.ID, + Transition: database.WorkspaceTransitionCreate, + }) + require.Error(t, err) + }) + + t.Run("CreateHistoryInvalidProjectVersion", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + _, workspace := setupProjectAndWorkspace(t, server.Client, user) + + _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ + ProjectHistoryID: uuid.New(), + Transition: database.WorkspaceTransitionCreate, + }) + require.Error(t, err) + }) +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index f12633a5611bf..60504fb2cc184 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -5,11 +5,9 @@ import ( "errors" "fmt" "net/http" - "time" "github.com/go-chi/render" "github.com/google/uuid" - "golang.org/x/xerrors" "github.com/coder/coder/database" "github.com/coder/coder/httpapi" @@ -20,67 +18,16 @@ import ( // project versions, and can be updated. type Workspace database.Workspace -// WorkspaceHistory is an at-point representation of a workspace state. -// Iterate on before/after to determine a chronological history. -type WorkspaceHistory struct { - ID uuid.UUID `json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CompletedAt time.Time `json:"completed_at"` - WorkspaceID uuid.UUID `json:"workspace_id"` - ProjectHistoryID uuid.UUID `json:"project_history_id"` - BeforeID uuid.UUID `json:"before_id"` - AfterID uuid.UUID `json:"after_id"` - Transition database.WorkspaceTransition `json:"transition"` - Initiator string `json:"initiator"` -} - // CreateWorkspaceRequest provides options for creating a new workspace. type CreateWorkspaceRequest struct { ProjectID uuid.UUID `json:"project_id" validate:"required"` Name string `json:"name" validate:"username,required"` } -// CreateWorkspaceHistoryRequest provides options to update the latest workspace history. -type CreateWorkspaceHistoryRequest struct { - ProjectHistoryID uuid.UUID `json:"project_history_id" validate:"required"` - Transition database.WorkspaceTransition `json:"transition" validate:"oneof=create start stop delete,required"` -} - -type workspaces struct { - Database database.Store -} - // Returns all workspaces across all projects and organizations. -func (w *workspaces) listAllWorkspaces(rw http.ResponseWriter, r *http.Request) { +func (api *api) workspaces(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) - workspaces, err := w.Database.GetWorkspacesByUserID(r.Context(), apiKey.UserID) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get workspaces: %s", err), - }) - return - } - - apiWorkspaces := make([]Workspace, 0, len(workspaces)) - for _, workspace := range workspaces { - apiWorkspaces = append(apiWorkspaces, convertWorkspace(workspace)) - } - render.Status(r, http.StatusOK) - render.JSON(rw, r, apiWorkspaces) -} - -// Returns all workspaces for a specific project. -func (w *workspaces) allWorkspacesForProject(rw http.ResponseWriter, r *http.Request) { - apiKey := httpmw.APIKey(r) - project := httpmw.ProjectParam(r) - workspaces, err := w.Database.GetWorkspacesByProjectAndUserID(r.Context(), database.GetWorkspacesByProjectAndUserIDParams{ - OwnerID: apiKey.UserID, - ProjectID: project.ID, - }) + workspaces, err := api.Database.GetWorkspacesByUserID(r.Context(), apiKey.UserID) if errors.Is(err, sql.ErrNoRows) { err = nil } @@ -100,13 +47,13 @@ func (w *workspaces) allWorkspacesForProject(rw http.ResponseWriter, r *http.Req } // Create a new workspace for the currently authenticated user. -func (w *workspaces) createWorkspaceForUser(rw http.ResponseWriter, r *http.Request) { +func (api *api) postWorkspaceByUser(rw http.ResponseWriter, r *http.Request) { var createWorkspace CreateWorkspaceRequest if !httpapi.Read(rw, r, &createWorkspace) { return } apiKey := httpmw.APIKey(r) - project, err := w.Database.GetProjectByID(r.Context(), createWorkspace.ProjectID) + project, err := api.Database.GetProjectByID(r.Context(), createWorkspace.ProjectID) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ Message: fmt.Sprintf("project %q doesn't exist", createWorkspace.ProjectID.String()), @@ -123,7 +70,7 @@ func (w *workspaces) createWorkspaceForUser(rw http.ResponseWriter, r *http.Requ }) return } - _, err = w.Database.GetOrganizationMemberByUserID(r.Context(), database.GetOrganizationMemberByUserIDParams{ + _, err = api.Database.GetOrganizationMemberByUserID(r.Context(), database.GetOrganizationMemberByUserIDParams{ OrganizationID: project.OrganizationID, UserID: apiKey.UserID, }) @@ -140,13 +87,13 @@ func (w *workspaces) createWorkspaceForUser(rw http.ResponseWriter, r *http.Requ return } - workspace, err := w.Database.GetWorkspaceByUserIDAndName(r.Context(), database.GetWorkspaceByUserIDAndNameParams{ + workspace, err := api.Database.GetWorkspaceByUserIDAndName(r.Context(), database.GetWorkspaceByUserIDAndNameParams{ OwnerID: apiKey.UserID, Name: createWorkspace.Name, }) if err == nil { // If the workspace already exists, don't allow creation. - project, err := w.Database.GetProjectByID(r.Context(), workspace.ProjectID) + project, err := api.Database.GetProjectByID(r.Context(), workspace.ProjectID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("find project for conflicting workspace name %q: %s", createWorkspace.Name, err), @@ -171,7 +118,7 @@ func (w *workspaces) createWorkspaceForUser(rw http.ResponseWriter, r *http.Requ } // Workspaces are created without any versions. - workspace, err = w.Database.InsertWorkspace(r.Context(), database.InsertWorkspaceParams{ + workspace, err = api.Database.InsertWorkspace(r.Context(), database.InsertWorkspaceParams{ ID: uuid.New(), CreatedAt: database.Now(), UpdatedAt: database.Now(), @@ -191,157 +138,13 @@ func (w *workspaces) createWorkspaceForUser(rw http.ResponseWriter, r *http.Requ } // Returns a single singleWorkspace. -func (*workspaces) singleWorkspace(rw http.ResponseWriter, r *http.Request) { +func (*api) workspaceByUser(rw http.ResponseWriter, r *http.Request) { workspace := httpmw.WorkspaceParam(r) render.Status(r, http.StatusOK) render.JSON(rw, r, convertWorkspace(workspace)) } -// Returns all workspace history. This is not sorted. Use before/after to chronologically sort. -func (w *workspaces) listAllWorkspaceHistory(rw http.ResponseWriter, r *http.Request) { - workspace := httpmw.WorkspaceParam(r) - - histories, err := w.Database.GetWorkspaceHistoryByWorkspaceID(r.Context(), workspace.ID) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get workspace history: %s", err), - }) - return - } - - apiHistory := make([]WorkspaceHistory, 0, len(histories)) - for _, history := range histories { - apiHistory = append(apiHistory, convertWorkspaceHistory(history)) - } - - render.Status(r, http.StatusOK) - render.JSON(rw, r, apiHistory) -} - -// Returns the latest workspace history. This works by querying for history without "after" set. -func (w *workspaces) latestWorkspaceHistory(rw http.ResponseWriter, r *http.Request) { - workspace := httpmw.WorkspaceParam(r) - - history, err := w.Database.GetWorkspaceHistoryByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) - if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ - Message: "workspace has no history", - }) - return - } - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get workspace history: %s", err), - }) - return - } - - render.Status(r, http.StatusOK) - render.JSON(rw, r, convertWorkspaceHistory(history)) -} - -// Begins transitioning a workspace to new state. This queues a provision job to asyncronously -// update the underlying infrastructure. Only one historical transition can occur at a time. -func (w *workspaces) createWorkspaceHistory(rw http.ResponseWriter, r *http.Request) { - var createBuild CreateWorkspaceHistoryRequest - if !httpapi.Read(rw, r, &createBuild) { - return - } - user := httpmw.UserParam(r) - workspace := httpmw.WorkspaceParam(r) - projectHistory, err := w.Database.GetProjectHistoryByID(r.Context(), createBuild.ProjectHistoryID) - if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: "project history not found", - Errors: []httpapi.Error{{ - Field: "project_history_id", - Code: "exists", - }}, - }) - return - } - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get project history: %s", err), - }) - return - } - - // Store prior history ID if it exists to update it after we create new! - priorHistoryID := uuid.NullUUID{} - priorHistory, err := w.Database.GetWorkspaceHistoryByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) - if err == nil { - if !priorHistory.CompletedAt.Valid { - httpapi.Write(rw, http.StatusConflict, httpapi.Response{ - Message: "a workspace build is already active", - }) - return - } - - priorHistoryID = uuid.NullUUID{ - UUID: priorHistory.ID, - Valid: true, - } - } - if !errors.Is(err, sql.ErrNoRows) { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get prior workspace history: %s", err), - }) - return - } - - var workspaceHistory database.WorkspaceHistory - // This must happen in a transaction to ensure history can be inserted, and - // the prior history can update it's "after" column to point at the new. - err = w.Database.InTx(func(db database.Store) error { - workspaceHistory, err = db.InsertWorkspaceHistory(r.Context(), database.InsertWorkspaceHistoryParams{ - ID: uuid.New(), - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - WorkspaceID: workspace.ID, - ProjectHistoryID: projectHistory.ID, - BeforeID: priorHistoryID, - Initiator: user.ID, - Transition: createBuild.Transition, - // This should create a provision job once that gets implemented! - ProvisionJobID: uuid.New(), - }) - if err != nil { - return xerrors.Errorf("insert workspace history: %w", err) - } - - if priorHistoryID.Valid { - // Update the prior history entries "after" column. - err = db.UpdateWorkspaceHistoryByID(r.Context(), database.UpdateWorkspaceHistoryByIDParams{ - ID: priorHistory.ID, - UpdatedAt: database.Now(), - AfterID: uuid.NullUUID{ - UUID: workspaceHistory.ID, - Valid: true, - }, - }) - if err != nil { - return xerrors.Errorf("update prior workspace history: %w", err) - } - } - - return nil - }) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: err.Error(), - }) - return - } - - render.Status(r, http.StatusCreated) - render.JSON(rw, r, convertWorkspaceHistory(workspaceHistory)) -} - // Converts the internal workspace representation to a public external-facing model. func convertWorkspace(workspace database.Workspace) Workspace { return Workspace(workspace) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 37e40a284e2f9..2ff351813694a 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1,8 +1,6 @@ package coderd_test import ( - "archive/tar" - "bytes" "context" "testing" @@ -34,31 +32,13 @@ func TestWorkspaces(t *testing.T) { }) require.NoError(t, err) workspace, err := client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{ - Name: "hiii", + Name: "example", ProjectID: project.ID, }) require.NoError(t, err) return project, workspace } - setupProjectVersion := func(t *testing.T, client *codersdk.Client, user coderd.CreateInitialUserRequest, project coderd.Project) coderd.ProjectHistory { - var buffer bytes.Buffer - writer := tar.NewWriter(&buffer) - err := writer.WriteHeader(&tar.Header{ - Name: "file", - Size: 1 << 10, - }) - require.NoError(t, err) - _, err = writer.Write(make([]byte, 1<<10)) - require.NoError(t, err) - projectHistory, err := client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{ - StorageMethod: database.ProjectStorageMethodInlineArchive, - StorageSource: buffer.Bytes(), - }) - require.NoError(t, err) - return projectHistory - } - t.Run("List", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) @@ -132,12 +112,12 @@ func TestWorkspaces(t *testing.T) { _, err = server.Client.CreateUser(context.Background(), coderd.CreateUserRequest{ Email: "hello@ok.io", Username: "example", - Password: "wowowow", + Password: "password", }) require.NoError(t, err) token, err := server.Client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{ Email: "hello@ok.io", - Password: "wowowow", + Password: "password", }) require.NoError(t, err) err = server.Client.SetSessionToken(token.SessionToken) @@ -169,87 +149,4 @@ func TestWorkspaces(t *testing.T) { _, err := server.Client.Workspace(context.Background(), "", workspace.Name) require.NoError(t, err) }) - - t.Run("AllHistory", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, workspace := setupProjectAndWorkspace(t, server.Client, user) - history, err := server.Client.WorkspaceHistory(context.Background(), "", workspace.Name) - require.NoError(t, err) - require.Len(t, history, 0) - projectVersion := setupProjectVersion(t, server.Client, user, project) - _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ - ProjectHistoryID: projectVersion.ID, - Transition: database.WorkspaceTransitionCreate, - }) - require.NoError(t, err) - history, err = server.Client.WorkspaceHistory(context.Background(), "", workspace.Name) - require.NoError(t, err) - require.Len(t, history, 1) - }) - - t.Run("LatestHistory", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, workspace := setupProjectAndWorkspace(t, server.Client, user) - _, err := server.Client.LatestWorkspaceHistory(context.Background(), "", workspace.Name) - require.Error(t, err) - projectVersion := setupProjectVersion(t, server.Client, user, project) - _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ - ProjectHistoryID: projectVersion.ID, - Transition: database.WorkspaceTransitionCreate, - }) - require.NoError(t, err) - _, err = server.Client.LatestWorkspaceHistory(context.Background(), "", workspace.Name) - require.NoError(t, err) - }) - - t.Run("CreateHistory", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, workspace := setupProjectAndWorkspace(t, server.Client, user) - projectHistory := setupProjectVersion(t, server.Client, user, project) - - _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ - ProjectHistoryID: projectHistory.ID, - Transition: database.WorkspaceTransitionCreate, - }) - require.NoError(t, err) - }) - - t.Run("CreateHistoryAlreadyInProgress", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, workspace := setupProjectAndWorkspace(t, server.Client, user) - projectHistory := setupProjectVersion(t, server.Client, user, project) - - _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ - ProjectHistoryID: projectHistory.ID, - Transition: database.WorkspaceTransitionCreate, - }) - require.NoError(t, err) - - _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ - ProjectHistoryID: projectHistory.ID, - Transition: database.WorkspaceTransitionCreate, - }) - require.Error(t, err) - }) - - t.Run("CreateHistoryInvalidProjectVersion", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - _, workspace := setupProjectAndWorkspace(t, server.Client, user) - - _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ - ProjectHistoryID: uuid.New(), - Transition: database.WorkspaceTransitionCreate, - }) - require.Error(t, err) - }) } diff --git a/codersdk/projects.go b/codersdk/projects.go index a075ebee084db..4b3a4e90e15d6 100644 --- a/codersdk/projects.go +++ b/codersdk/projects.go @@ -72,7 +72,7 @@ func (c *Client) ProjectHistory(ctx context.Context, organization, project strin } // CreateProjectHistory inserts a new version for the project. -func (c *Client) CreateProjectHistory(ctx context.Context, organization, project string, request coderd.CreateProjectVersionRequest) (coderd.ProjectHistory, error) { +func (c *Client) CreateProjectHistory(ctx context.Context, organization, project string, request coderd.CreateProjectHistoryRequest) (coderd.ProjectHistory, error) { res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/projects/%s/%s/history", organization, project), request) if err != nil { return coderd.ProjectHistory{}, err diff --git a/codersdk/projects_test.go b/codersdk/projects_test.go index acff520cb8c56..ad61d79110288 100644 --- a/codersdk/projects_test.go +++ b/codersdk/projects_test.go @@ -94,7 +94,7 @@ func TestProjects(t *testing.T) { t.Run("CreateVersionUnauthenticated", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) - _, err := server.Client.CreateProjectHistory(context.Background(), "org", "project", coderd.CreateProjectVersionRequest{ + _, err := server.Client.CreateProjectHistory(context.Background(), "org", "project", coderd.CreateProjectHistoryRequest{ StorageMethod: database.ProjectStorageMethodInlineArchive, StorageSource: []byte{}, }) @@ -119,7 +119,7 @@ func TestProjects(t *testing.T) { require.NoError(t, err) _, err = writer.Write(make([]byte, 1<<10)) require.NoError(t, err) - _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{ + _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectHistoryRequest{ StorageMethod: database.ProjectStorageMethodInlineArchive, StorageSource: buffer.Bytes(), }) diff --git a/go.mod b/go.mod index cfe961dbd1544..31b5af9012165 100644 --- a/go.mod +++ b/go.mod @@ -108,6 +108,7 @@ require ( github.com/zeebo/errs v1.2.2 // indirect go.opencensus.io v0.23.0 // indirect golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba // indirect + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.3.7 // indirect @@ -116,4 +117,5 @@ require ( google.golang.org/grpc v1.44.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + nhooyr.io/websocket v1.8.7 // indirect )