feat: Add APIs for querying workspaces (#61)

* Add SQL migration

* Add query functions for workspaces

* Add create routes

* Add tests for codersdk

* Add workspace parameter route

* Add workspace query

* Move workspace function

* Add querying for workspace history

* Fix query

* Fix syntax error

* Move workspace routes

* Fix version

* Add CLI tests

* Fix syntax error

* Remove error

* Fix history error

* Add new user test

* Fix test

* Lower target to 70%

* Improve comments

* Add comment
This commit is contained in:
Kyle Carberry
2022-01-25 13:52:58 -06:00
committed by GitHub
parent 139828d594
commit 5b01f615eb
27 changed files with 2511 additions and 81 deletions

View File

@ -26,6 +26,9 @@ func New(options *Options) http.Handler {
users := &users{
Database: options.Database,
}
workspaces := &workspaces{
Database: options.Database,
}
r := chi.NewRouter()
r.Route("/api/v2", func(r chi.Router) {
@ -36,14 +39,15 @@ func New(options *Options) http.Handler {
})
r.Post("/login", users.loginWithPassword)
r.Post("/logout", users.logout)
// Used for setup.
r.Post("/user", users.createInitialUser)
r.Route("/users", func(r chi.Router) {
r.Post("/", users.createInitialUser)
r.Use(
httpmw.ExtractAPIKey(options.Database, nil),
)
r.Post("/", users.createUser)
r.Group(func(r chi.Router) {
r.Use(
httpmw.ExtractAPIKey(options.Database, nil),
httpmw.ExtractUserParam(options.Database),
)
r.Use(httpmw.ExtractUserParam(options.Database))
r.Get("/{user}", users.user)
r.Get("/{user}/organizations", users.userOrganizations)
})
@ -58,11 +62,33 @@ func New(options *Options) http.Handler {
r.Get("/", projects.allProjectsForOrganization)
r.Post("/", projects.createProject)
r.Route("/{project}", func(r chi.Router) {
r.Use(httpmw.ExtractProjectParameter(options.Database))
r.Use(httpmw.ExtractProjectParam(options.Database))
r.Get("/", projects.project)
r.Route("/versions", func(r chi.Router) {
r.Get("/", projects.projectVersions)
r.Post("/", projects.createProjectVersion)
r.Route("/history", func(r chi.Router) {
r.Get("/", projects.allProjectHistory)
r.Post("/", projects.createProjectHistory)
})
r.Get("/workspaces", workspaces.allWorkspacesForProject)
})
})
})
// Listing operations specific to resources should go under
// their respective routes. eg. /orgs/<name>/workspaces
r.Route("/workspaces", func(r chi.Router) {
r.Use(httpmw.ExtractAPIKey(options.Database, nil))
r.Get("/", workspaces.listAllWorkspaces)
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Get("/", workspaces.listAllWorkspaces)
r.Post("/", workspaces.createWorkspaceForUser)
r.Route("/{workspace}", func(r chi.Router) {
r.Use(httpmw.ExtractWorkspaceParam(options.Database))
r.Get("/", workspaces.singleWorkspace)
r.Route("/history", func(r chi.Router) {
r.Post("/", workspaces.createWorkspaceHistory)
r.Get("/", workspaces.listAllWorkspaceHistory)
r.Get("/latest", workspaces.latestWorkspaceHistory)
})
})
})

View File

@ -24,8 +24,8 @@ import (
// abstracted for ease of change later on.
type Project database.Project
// ProjectVersion is the JSON representation of a Coder project version.
type ProjectVersion struct {
// 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"`
@ -42,7 +42,6 @@ type CreateProjectRequest struct {
// CreateProjectVersionRequest enables callers to create a new Project Version.
type CreateProjectVersionRequest struct {
Name string `json:"name,omitempty" validate:"username"`
StorageMethod database.ProjectStorageMethod `json:"storage_method" validate:"oneof=inline-archive,required"`
StorageSource []byte `json:"storage_source" validate:"max=1048576,required"`
}
@ -51,7 +50,7 @@ type projects struct {
Database database.Store
}
// allProjects lists all projects across organizations for a user.
// Lists all projects the authenticated user has access to.
func (p *projects) allProjects(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
organizations, err := p.Database.GetOrganizationsByUserID(r.Context(), apiKey.UserID)
@ -79,7 +78,7 @@ func (p *projects) allProjects(rw http.ResponseWriter, r *http.Request) {
render.JSON(rw, r, projects)
}
// allProjectsForOrganization lists all projects for a specific organization.
// Lists all projects in an organization.
func (p *projects) allProjectsForOrganization(rw http.ResponseWriter, r *http.Request) {
organization := httpmw.OrganizationParam(r)
projects, err := p.Database.GetProjectsByOrganizationIDs(r.Context(), []string{organization.ID})
@ -96,7 +95,7 @@ func (p *projects) allProjectsForOrganization(rw http.ResponseWriter, r *http.Re
render.JSON(rw, r, projects)
}
// createProject makes a new project in an organization.
// Creates a new project in an organization.
func (p *projects) createProject(rw http.ResponseWriter, r *http.Request) {
var createProject CreateProjectRequest
if !httpapi.Read(rw, r, &createProject) {
@ -142,7 +141,7 @@ func (p *projects) createProject(rw http.ResponseWriter, r *http.Request) {
render.JSON(rw, r, project)
}
// project returns a single project parsed from the URL path.
// Returns a single project.
func (*projects) project(rw http.ResponseWriter, r *http.Request) {
project := httpmw.ProjectParam(r)
@ -150,8 +149,8 @@ func (*projects) project(rw http.ResponseWriter, r *http.Request) {
render.JSON(rw, r, project)
}
// projectVersions lists versions for a single project.
func (p *projects) projectVersions(rw http.ResponseWriter, r *http.Request) {
// Lists history for a single project.
func (p *projects) allProjectHistory(rw http.ResponseWriter, r *http.Request) {
project := httpmw.ProjectParam(r)
history, err := p.Database.GetProjectHistoryByProjectID(r.Context(), project.ID)
@ -164,15 +163,18 @@ func (p *projects) projectVersions(rw http.ResponseWriter, r *http.Request) {
})
return
}
versions := make([]ProjectVersion, 0)
apiHistory := make([]ProjectHistory, 0)
for _, version := range history {
versions = append(versions, convertProjectHistory(version))
apiHistory = append(apiHistory, convertProjectHistory(version))
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, versions)
render.JSON(rw, r, apiHistory)
}
func (p *projects) createProjectVersion(rw http.ResponseWriter, r *http.Request) {
// 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
@ -204,6 +206,8 @@ func (p *projects) createProjectVersion(rw http.ResponseWriter, r *http.Request)
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{
@ -218,8 +222,8 @@ func (p *projects) createProjectVersion(rw http.ResponseWriter, r *http.Request)
render.JSON(rw, r, convertProjectHistory(history))
}
func convertProjectHistory(history database.ProjectHistory) ProjectVersion {
return ProjectVersion{
func convertProjectHistory(history database.ProjectHistory) ProjectHistory {
return ProjectHistory{
ID: history.ID,
ProjectID: history.ProjectID,
CreatedAt: history.CreatedAt,

View File

@ -104,7 +104,7 @@ func TestProjects(t *testing.T) {
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
versions, err := server.Client.ProjectVersions(context.Background(), user.Organization, project.Name)
versions, err := server.Client.ProjectHistory(context.Background(), user.Organization, project.Name)
require.NoError(t, err)
require.Len(t, versions, 0)
})
@ -127,13 +127,12 @@ func TestProjects(t *testing.T) {
require.NoError(t, err)
_, err = writer.Write(make([]byte, 1<<10))
require.NoError(t, err)
_, err = server.Client.CreateProjectVersion(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{
Name: "moo",
_, 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.ProjectVersions(context.Background(), user.Organization, project.Name)
versions, err := server.Client.ProjectHistory(context.Background(), user.Organization, project.Name)
require.NoError(t, err)
require.Len(t, versions, 1)
})
@ -156,8 +155,7 @@ func TestProjects(t *testing.T) {
require.NoError(t, err)
_, err = writer.Write(make([]byte, 1<<21))
require.NoError(t, err)
_, err = server.Client.CreateProjectVersion(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{
Name: "moo",
_, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{
StorageMethod: database.ProjectStorageMethodInlineArchive,
StorageSource: buffer.Bytes(),
})
@ -173,8 +171,7 @@ func TestProjects(t *testing.T) {
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
_, err = server.Client.CreateProjectVersion(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{
Name: "moo",
_, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{
StorageMethod: database.ProjectStorageMethodInlineArchive,
StorageSource: []byte{},
})

View File

@ -19,7 +19,7 @@ import (
"github.com/coder/coder/httpmw"
)
// User is the JSON representation of a Coder user.
// User represents a user in Coder.
type User struct {
ID string `json:"id" validate:"required"`
Email string `json:"email" validate:"required"`
@ -27,7 +27,9 @@ type User struct {
Username string `json:"username" validate:"required"`
}
// CreateInitialUserRequest enables callers to create a new user.
// CreateInitialUserRequest provides options to create the initial
// user for a Coder deployment. The organization provided will be
// created as well.
type CreateInitialUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
@ -35,6 +37,13 @@ type CreateInitialUserRequest struct {
Organization string `json:"organization" validate:"required,username"`
}
// CreateUserRequest provides options for creating a new user.
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Password string `json:"password" validate:"required"`
}
// LoginWithPasswordRequest enables callers to authenticate with email and password.
type LoginWithPasswordRequest struct {
Email string `json:"email" validate:"required,email"`
@ -123,7 +132,58 @@ func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) {
}
render.Status(r, http.StatusCreated)
render.JSON(rw, r, user)
render.JSON(rw, r, convertUser(user))
}
// Creates a new user.
func (users *users) createUser(rw http.ResponseWriter, r *http.Request) {
var createUser CreateUserRequest
if !httpapi.Read(rw, r, &createUser) {
return
}
_, err := users.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{
Username: createUser.Username,
Email: createUser.Email,
})
if err == nil {
httpapi.Write(rw, http.StatusConflict, httpapi.Response{
Message: "user already exists",
})
return
}
if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get user: %s", err),
})
return
}
hashedPassword, err := userpassword.Hash(createUser.Password)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("hash password: %s", err.Error()),
})
return
}
user, err := users.Database.InsertUser(r.Context(), database.InsertUserParams{
ID: uuid.NewString(),
Email: createUser.Email,
HashedPassword: []byte(hashedPassword),
Username: createUser.Username,
LoginType: database.LoginTypeBuiltIn,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("create user: %s", err.Error()),
})
return
}
render.Status(r, http.StatusCreated)
render.JSON(rw, r, convertUser(user))
}
// Returns the parameterized user requested. All validation
@ -131,12 +191,7 @@ func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) {
func (*users) user(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
render.JSON(rw, r, User{
ID: user.ID,
Email: user.Email,
CreatedAt: user.CreatedAt,
Username: user.Username,
})
render.JSON(rw, r, convertUser(user))
}
// Returns organizations the parameterized user has access to.
@ -265,3 +320,12 @@ func generateAPIKeyIDSecret() (id string, secret string, err error) {
}
return id, secret, nil
}
func convertUser(user database.User) User {
return User{
ID: user.ID,
Email: user.Email,
CreatedAt: user.CreatedAt,
Username: user.Username,
}
}

View File

@ -76,6 +76,30 @@ func TestUsers(t *testing.T) {
require.NoError(t, err)
require.Len(t, orgs, 1)
})
t.Run("CreateUser", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_ = server.RandomInitialUser(t)
_, err := server.Client.CreateUser(context.Background(), coderd.CreateUserRequest{
Email: "wow@ok.io",
Username: "tomato",
Password: "bananas",
})
require.NoError(t, err)
})
t.Run("CreateUserConflict", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
_, err := server.Client.CreateUser(context.Background(), coderd.CreateUserRequest{
Email: "wow@ok.io",
Username: user.Username,
Password: "bananas",
})
require.Error(t, err)
})
}
func TestLogout(t *testing.T) {

365
coderd/workspaces.go Normal file
View File

@ -0,0 +1,365 @@
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"
)
// Workspace is a per-user deployment of a project. It tracks
// 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) {
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,
})
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)
}
// Create a new workspace for the currently authenticated user.
func (w *workspaces) createWorkspaceForUser(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)
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: fmt.Sprintf("project %q doesn't exist", createWorkspace.ProjectID.String()),
Errors: []httpapi.Error{{
Field: "project_id",
Code: "not_found",
}},
})
return
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get project: %s", err),
})
return
}
_, err = w.Database.GetOrganizationMemberByUserID(r.Context(), database.GetOrganizationMemberByUserIDParams{
OrganizationID: project.OrganizationID,
UserID: apiKey.UserID,
})
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: "you aren't allowed to access projects in that organization",
})
return
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get organization member: %s", err),
})
return
}
workspace, err := w.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)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("find project for conflicting workspace name %q: %s", createWorkspace.Name, err),
})
return
}
// The project is fetched for clarity to the user on where the conflicting name may be.
httpapi.Write(rw, http.StatusConflict, httpapi.Response{
Message: fmt.Sprintf("workspace %q already exists in the %q project", createWorkspace.Name, project.Name),
Errors: []httpapi.Error{{
Field: "name",
Code: "exists",
}},
})
return
}
if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspace by name: %s", err.Error()),
})
return
}
// Workspaces are created without any versions.
workspace, err = w.Database.InsertWorkspace(r.Context(), database.InsertWorkspaceParams{
ID: uuid.New(),
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
OwnerID: apiKey.UserID,
ProjectID: project.ID,
Name: createWorkspace.Name,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("insert workspace: %s", err),
})
return
}
render.Status(r, http.StatusCreated)
render.JSON(rw, r, convertWorkspace(workspace))
}
// Returns a single singleWorkspace.
func (*workspaces) singleWorkspace(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)
}
// Converts the internal history representation to a public external-facing model.
func convertWorkspaceHistory(workspaceHistory database.WorkspaceHistory) WorkspaceHistory {
//nolint:unconvert
return WorkspaceHistory(WorkspaceHistory{
ID: workspaceHistory.ID,
CreatedAt: workspaceHistory.CreatedAt,
UpdatedAt: workspaceHistory.UpdatedAt,
CompletedAt: workspaceHistory.CompletedAt.Time,
WorkspaceID: workspaceHistory.WorkspaceID,
ProjectHistoryID: workspaceHistory.ProjectHistoryID,
BeforeID: workspaceHistory.BeforeID.UUID,
AfterID: workspaceHistory.AfterID.UUID,
Transition: workspaceHistory.Transition,
Initiator: workspaceHistory.Initiator,
})
}

255
coderd/workspaces_test.go Normal file
View File

@ -0,0 +1,255 @@
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 TestWorkspaces(t *testing.T) {
t.Parallel()
t.Run("ListNone", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_ = server.RandomInitialUser(t)
workspaces, err := server.Client.WorkspacesByUser(context.Background(), "")
require.NoError(t, err)
require.Len(t, workspaces, 0)
})
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: "hiii",
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)
user := server.RandomInitialUser(t)
_, _ = setupProjectAndWorkspace(t, server.Client, user)
workspaces, err := server.Client.WorkspacesByUser(context.Background(), "")
require.NoError(t, err)
require.Len(t, workspaces, 1)
})
t.Run("ListNoneForProject", 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: "banana",
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
workspaces, err := server.Client.WorkspacesByProject(context.Background(), user.Organization, project.Name)
require.NoError(t, err)
require.Len(t, workspaces, 0)
})
t.Run("ListForProject", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
project, _ := setupProjectAndWorkspace(t, server.Client, user)
workspaces, err := server.Client.WorkspacesByProject(context.Background(), user.Organization, project.Name)
require.NoError(t, err)
require.Len(t, workspaces, 1)
})
t.Run("CreateInvalidInput", 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: "banana",
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
_, err = server.Client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{
ProjectID: project.ID,
Name: "$$$",
})
require.Error(t, err)
})
t.Run("CreateInvalidProject", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_ = server.RandomInitialUser(t)
_, err := server.Client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{
ProjectID: uuid.New(),
Name: "moo",
})
require.Error(t, err)
})
t.Run("CreateNotInProjectOrganization", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
initial := server.RandomInitialUser(t)
project, err := server.Client.CreateProject(context.Background(), initial.Organization, coderd.CreateProjectRequest{
Name: "banana",
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
_, err = server.Client.CreateUser(context.Background(), coderd.CreateUserRequest{
Email: "hello@ok.io",
Username: "example",
Password: "wowowow",
})
require.NoError(t, err)
token, err := server.Client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Email: "hello@ok.io",
Password: "wowowow",
})
require.NoError(t, err)
err = server.Client.SetSessionToken(token.SessionToken)
require.NoError(t, err)
_, err = server.Client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{
ProjectID: project.ID,
Name: "moo",
})
require.Error(t, err)
})
t.Run("CreateAlreadyExists", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
project, workspace := setupProjectAndWorkspace(t, server.Client, user)
_, err := server.Client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{
Name: workspace.Name,
ProjectID: project.ID,
})
require.Error(t, err)
})
t.Run("Single", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
_, workspace := setupProjectAndWorkspace(t, server.Client, user)
_, 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)
})
}