mirror of
https://github.com/coder/coder.git
synced 2025-07-08 11:39:50 +00:00
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:
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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,
|
||||
|
@ -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{},
|
||||
})
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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
365
coderd/workspaces.go
Normal 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
255
coderd/workspaces_test.go
Normal 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)
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user