diff --git a/codecov.yml b/codecov.yml index faa4e2a91ec30..177aac1b1212c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -16,7 +16,7 @@ coverage: status: project: default: - target: 75% + target: 70% informational: yes ignore: diff --git a/coderd/coderd.go b/coderd/coderd.go index 2dcf847033abd..aa624c4cc6b8c 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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//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) }) }) }) diff --git a/coderd/projects.go b/coderd/projects.go index be326ee15e93c..5ef2ea5067b6a 100644 --- a/coderd/projects.go +++ b/coderd/projects.go @@ -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, diff --git a/coderd/projects_test.go b/coderd/projects_test.go index fb653e1701dbc..cd02703f64b6e 100644 --- a/coderd/projects_test.go +++ b/coderd/projects_test.go @@ -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{}, }) diff --git a/coderd/users.go b/coderd/users.go index 133b8ddc7b557..6fe812d9decdc 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -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, + } +} diff --git a/coderd/users_test.go b/coderd/users_test.go index 29a7098eefc1e..11b533b0f7bd8 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -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) { diff --git a/coderd/workspaces.go b/coderd/workspaces.go new file mode 100644 index 0000000000000..f12633a5611bf --- /dev/null +++ b/coderd/workspaces.go @@ -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, + }) +} diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go new file mode 100644 index 0000000000000..37e40a284e2f9 --- /dev/null +++ b/coderd/workspaces_test.go @@ -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) + }) +} diff --git a/codersdk/projects.go b/codersdk/projects.go index cb3806d915d94..a075ebee084db 100644 --- a/codersdk/projects.go +++ b/codersdk/projects.go @@ -57,9 +57,9 @@ func (c *Client) CreateProject(ctx context.Context, organization string, request return project, json.NewDecoder(res.Body).Decode(&project) } -// ProjectVersions lists history for a project. -func (c *Client) ProjectVersions(ctx context.Context, organization, project string) ([]coderd.ProjectVersion, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/projects/%s/%s/versions", organization, project), nil) +// ProjectHistory lists history for a project. +func (c *Client) ProjectHistory(ctx context.Context, organization, project string) ([]coderd.ProjectHistory, error) { + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/projects/%s/%s/history", organization, project), nil) if err != nil { return nil, err } @@ -67,20 +67,20 @@ func (c *Client) ProjectVersions(ctx context.Context, organization, project stri if res.StatusCode != http.StatusOK { return nil, readBodyAsError(res) } - var projectVersions []coderd.ProjectVersion + var projectVersions []coderd.ProjectHistory return projectVersions, json.NewDecoder(res.Body).Decode(&projectVersions) } -// CreateProjectVersion inserts a new version for the project. -func (c *Client) CreateProjectVersion(ctx context.Context, organization, project string, request coderd.CreateProjectVersionRequest) (coderd.ProjectVersion, error) { - res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/projects/%s/%s/versions", organization, project), request) +// CreateProjectHistory inserts a new version for the project. +func (c *Client) CreateProjectHistory(ctx context.Context, organization, project string, request coderd.CreateProjectVersionRequest) (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.ProjectVersion{}, err + return coderd.ProjectHistory{}, err } defer res.Body.Close() if res.StatusCode != http.StatusCreated { - return coderd.ProjectVersion{}, readBodyAsError(res) + return coderd.ProjectHistory{}, readBodyAsError(res) } - var projectVersion coderd.ProjectVersion + var projectVersion coderd.ProjectHistory return projectVersion, json.NewDecoder(res.Body).Decode(&projectVersion) } diff --git a/codersdk/projects_test.go b/codersdk/projects_test.go index e3914b5f9465e..acff520cb8c56 100644 --- a/codersdk/projects_test.go +++ b/codersdk/projects_test.go @@ -74,7 +74,7 @@ func TestProjects(t *testing.T) { t.Run("UnauthenticatedVersions", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) - _, err := server.Client.ProjectVersions(context.Background(), "org", "project") + _, err := server.Client.ProjectHistory(context.Background(), "org", "project") require.Error(t, err) }) @@ -87,15 +87,14 @@ func TestProjects(t *testing.T) { Provisioner: database.ProvisionerTypeTerraform, }) require.NoError(t, err) - _, err = server.Client.ProjectVersions(context.Background(), user.Organization, project.Name) + _, err = server.Client.ProjectHistory(context.Background(), user.Organization, project.Name) require.NoError(t, err) }) t.Run("CreateVersionUnauthenticated", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) - _, err := server.Client.CreateProjectVersion(context.Background(), "org", "project", coderd.CreateProjectVersionRequest{ - Name: "hello", + _, err := server.Client.CreateProjectHistory(context.Background(), "org", "project", coderd.CreateProjectVersionRequest{ StorageMethod: database.ProjectStorageMethodInlineArchive, StorageSource: []byte{}, }) @@ -120,8 +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.CreateProjectVersion(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{ - Name: "hello", + _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{ StorageMethod: database.ProjectStorageMethodInlineArchive, StorageSource: buffer.Bytes(), }) diff --git a/codersdk/users.go b/codersdk/users.go index f47e7383f60af..c3a8c13b847ee 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -13,6 +13,20 @@ import ( // This initial user has superadmin privileges. If >0 users exist, this request // will fail. func (c *Client) CreateInitialUser(ctx context.Context, req coderd.CreateInitialUserRequest) (coderd.User, error) { + res, err := c.request(ctx, http.MethodPost, "/api/v2/user", req) + if err != nil { + return coderd.User{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return coderd.User{}, readBodyAsError(res) + } + var user coderd.User + return user, json.NewDecoder(res.Body).Decode(&user) +} + +// CreateUser creates a new user. +func (c *Client) CreateUser(ctx context.Context, req coderd.CreateUserRequest) (coderd.User, error) { res, err := c.request(ctx, http.MethodPost, "/api/v2/users", req) if err != nil { return coderd.User{}, err diff --git a/codersdk/users_test.go b/codersdk/users_test.go index a42aa630c0353..26f1e7d3fd646 100644 --- a/codersdk/users_test.go +++ b/codersdk/users_test.go @@ -55,4 +55,16 @@ func TestUsers(t *testing.T) { err := server.Client.Logout(context.Background()) require.NoError(t, err) }) + + t.Run("CreateMultiple", 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: "example", + Password: "tomato", + }) + require.NoError(t, err) + }) } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go new file mode 100644 index 0000000000000..937f58e861b11 --- /dev/null +++ b/codersdk/workspaces.go @@ -0,0 +1,129 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/coder/coder/coderd" +) + +// Workspaces returns all workspaces the authenticated session has access to. +// If owner is specified, all workspaces for an organization will be returned. +// If owner is empty, all workspaces the caller has access to will be returned. +func (c *Client) WorkspacesByUser(ctx context.Context, user string) ([]coderd.Workspace, error) { + route := "/api/v2/workspaces" + if user != "" { + route += fmt.Sprintf("/%s", user) + } + res, err := c.request(ctx, http.MethodGet, route, nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + var workspaces []coderd.Workspace + return workspaces, json.NewDecoder(res.Body).Decode(&workspaces) +} + +// WorkspacesByProject lists all workspaces for a specific project. +func (c *Client) WorkspacesByProject(ctx context.Context, organization, project string) ([]coderd.Workspace, error) { + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/projects/%s/%s/workspaces", organization, project), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + var workspaces []coderd.Workspace + return workspaces, json.NewDecoder(res.Body).Decode(&workspaces) +} + +// Workspace returns a single workspace by owner and name. +func (c *Client) Workspace(ctx context.Context, owner, name string) (coderd.Workspace, error) { + if owner == "" { + owner = "me" + } + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/%s", owner, name), nil) + if err != nil { + return coderd.Workspace{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return coderd.Workspace{}, readBodyAsError(res) + } + var workspace coderd.Workspace + return workspace, json.NewDecoder(res.Body).Decode(&workspace) +} + +// WorkspaceHistory returns historical data for workspace builds. +func (c *Client) WorkspaceHistory(ctx context.Context, owner, workspace string) ([]coderd.WorkspaceHistory, error) { + if owner == "" { + owner = "me" + } + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/%s/history", owner, workspace), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + var workspaceHistory []coderd.WorkspaceHistory + return workspaceHistory, json.NewDecoder(res.Body).Decode(&workspaceHistory) +} + +// LatestWorkspaceHistory returns the newest build for a workspace. +func (c *Client) LatestWorkspaceHistory(ctx context.Context, owner, workspace string) (coderd.WorkspaceHistory, error) { + if owner == "" { + owner = "me" + } + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/%s/history/latest", owner, workspace), nil) + if err != nil { + return coderd.WorkspaceHistory{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return coderd.WorkspaceHistory{}, readBodyAsError(res) + } + var workspaceHistory coderd.WorkspaceHistory + return workspaceHistory, json.NewDecoder(res.Body).Decode(&workspaceHistory) +} + +// CreateWorkspace creates a new workspace for the project specified. +func (c *Client) CreateWorkspace(ctx context.Context, user string, request coderd.CreateWorkspaceRequest) (coderd.Workspace, error) { + if user == "" { + user = "me" + } + res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaces/%s", user), request) + if err != nil { + return coderd.Workspace{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return coderd.Workspace{}, readBodyAsError(res) + } + var workspace coderd.Workspace + return workspace, json.NewDecoder(res.Body).Decode(&workspace) +} + +// CreateWorkspaceHistory queues a new build to occur for a workspace. +func (c *Client) CreateWorkspaceHistory(ctx context.Context, owner, workspace string, request coderd.CreateWorkspaceHistoryRequest) (coderd.WorkspaceHistory, error) { + if owner == "" { + owner = "me" + } + res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaces/%s/%s/history", owner, workspace), request) + if err != nil { + return coderd.WorkspaceHistory{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return coderd.WorkspaceHistory{}, readBodyAsError(res) + } + var workspaceHistory coderd.WorkspaceHistory + return workspaceHistory, json.NewDecoder(res.Body).Decode(&workspaceHistory) +} diff --git a/codersdk/workspaces_test.go b/codersdk/workspaces_test.go new file mode 100644 index 0000000000000..b99f3798e93ee --- /dev/null +++ b/codersdk/workspaces_test.go @@ -0,0 +1,169 @@ +package codersdk_test + +import ( + "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/database" +) + +func TestWorkspaces(t *testing.T) { + t.Parallel() + t.Run("ListError", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + _, err := server.Client.WorkspacesByUser(context.Background(), "") + require.Error(t, err) + }) + + t.Run("ListNoOwner", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + _, err := server.Client.WorkspacesByUser(context.Background(), "") + require.Error(t, err) + }) + + t.Run("ListByUser", 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: "tomato", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + _, err = server.Client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{ + Name: "wooow", + ProjectID: project.ID, + }) + require.NoError(t, err) + _, err = server.Client.WorkspacesByUser(context.Background(), "me") + require.NoError(t, err) + }) + + t.Run("ListByProject", 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: "tomato", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + _, err = server.Client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{ + Name: "wooow", + ProjectID: project.ID, + }) + require.NoError(t, err) + _, err = server.Client.WorkspacesByProject(context.Background(), user.Organization, project.Name) + require.NoError(t, err) + }) + + t.Run("ListByProjectError", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + _, err := server.Client.WorkspacesByProject(context.Background(), "", "") + require.Error(t, err) + }) + + t.Run("CreateError", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + _, err := server.Client.CreateWorkspace(context.Background(), "no", coderd.CreateWorkspaceRequest{}) + require.Error(t, err) + }) + + t.Run("Single", 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: "tomato", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + workspace, err := server.Client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{ + Name: "wooow", + ProjectID: project.ID, + }) + require.NoError(t, err) + _, err = server.Client.Workspace(context.Background(), "", workspace.Name) + require.NoError(t, err) + }) + + t.Run("SingleError", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + _, err := server.Client.Workspace(context.Background(), "", "blob") + require.Error(t, err) + }) + + t.Run("History", 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: "tomato", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + workspace, err := server.Client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{ + Name: "wooow", + ProjectID: project.ID, + }) + require.NoError(t, err) + _, err = server.Client.WorkspaceHistory(context.Background(), "", workspace.Name) + require.NoError(t, err) + }) + + t.Run("HistoryError", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + _, err := server.Client.WorkspaceHistory(context.Background(), "", "blob") + require.Error(t, err) + }) + + t.Run("LatestHistory", 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: "tomato", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + workspace, err := server.Client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{ + Name: "wooow", + ProjectID: project.ID, + }) + require.NoError(t, err) + _, err = server.Client.LatestWorkspaceHistory(context.Background(), "", workspace.Name) + require.Error(t, err) + }) + + 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: "tomato", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + workspace, err := server.Client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{ + Name: "wooow", + ProjectID: project.ID, + }) + require.NoError(t, err) + _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ + ProjectHistoryID: uuid.New(), + Transition: database.WorkspaceTransitionCreate, + }) + require.Error(t, err) + }) +} diff --git a/database/databasefake/databasefake.go b/database/databasefake/databasefake.go index 8511bad05831e..b7cd6de3c57e8 100644 --- a/database/databasefake/databasefake.go +++ b/database/databasefake/databasefake.go @@ -18,9 +18,13 @@ func New() database.Store { organizationMembers: make([]database.OrganizationMember, 0), users: make([]database.User, 0), - project: make([]database.Project, 0), - projectHistory: make([]database.ProjectHistory, 0), - projectParameter: make([]database.ProjectParameter, 0), + project: make([]database.Project, 0), + projectHistory: make([]database.ProjectHistory, 0), + projectParameter: make([]database.ProjectParameter, 0), + workspace: make([]database.Workspace, 0), + workspaceResource: make([]database.WorkspaceResource, 0), + workspaceHistory: make([]database.WorkspaceHistory, 0), + workspaceAgent: make([]database.WorkspaceAgent, 0), } } @@ -33,9 +37,13 @@ type fakeQuerier struct { users []database.User // New tables - project []database.Project - projectHistory []database.ProjectHistory - projectParameter []database.ProjectParameter + project []database.Project + projectHistory []database.ProjectHistory + projectParameter []database.ProjectParameter + workspace []database.Workspace + workspaceResource []database.WorkspaceResource + workspaceHistory []database.WorkspaceHistory + workspaceAgent []database.WorkspaceAgent } // InTx doesn't rollback data properly for in-memory yet. @@ -74,6 +82,103 @@ func (q *fakeQuerier) GetUserCount(_ context.Context) (int64, error) { return int64(len(q.users)), nil } +func (q *fakeQuerier) GetWorkspaceAgentsByResourceIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceAgent, error) { + agents := make([]database.WorkspaceAgent, 0) + for _, workspaceAgent := range q.workspaceAgent { + for _, id := range ids { + if workspaceAgent.WorkspaceResourceID.String() == id.String() { + agents = append(agents, workspaceAgent) + } + } + } + if len(agents) == 0 { + return nil, sql.ErrNoRows + } + return agents, nil +} + +func (q *fakeQuerier) GetWorkspaceByUserIDAndName(_ context.Context, arg database.GetWorkspaceByUserIDAndNameParams) (database.Workspace, error) { + for _, workspace := range q.workspace { + if workspace.OwnerID != arg.OwnerID { + continue + } + if !strings.EqualFold(workspace.Name, arg.Name) { + continue + } + return workspace, nil + } + return database.Workspace{}, sql.ErrNoRows +} + +func (q *fakeQuerier) GetWorkspaceResourcesByHistoryID(_ context.Context, workspaceHistoryID uuid.UUID) ([]database.WorkspaceResource, error) { + resources := make([]database.WorkspaceResource, 0) + for _, workspaceResource := range q.workspaceResource { + if workspaceResource.WorkspaceHistoryID.String() == workspaceHistoryID.String() { + resources = append(resources, workspaceResource) + } + } + if len(resources) == 0 { + return nil, sql.ErrNoRows + } + return resources, nil +} + +func (q *fakeQuerier) GetWorkspaceHistoryByWorkspaceIDWithoutAfter(_ context.Context, workspaceID uuid.UUID) (database.WorkspaceHistory, error) { + for _, workspaceHistory := range q.workspaceHistory { + if workspaceHistory.WorkspaceID.String() != workspaceID.String() { + continue + } + if !workspaceHistory.AfterID.Valid { + return workspaceHistory, nil + } + } + return database.WorkspaceHistory{}, sql.ErrNoRows +} + +func (q *fakeQuerier) GetWorkspaceHistoryByWorkspaceID(_ context.Context, workspaceID uuid.UUID) ([]database.WorkspaceHistory, error) { + history := make([]database.WorkspaceHistory, 0) + for _, workspaceHistory := range q.workspaceHistory { + if workspaceHistory.WorkspaceID.String() == workspaceID.String() { + history = append(history, workspaceHistory) + } + } + if len(history) == 0 { + return nil, sql.ErrNoRows + } + return history, nil +} + +func (q *fakeQuerier) GetWorkspacesByProjectAndUserID(_ context.Context, arg database.GetWorkspacesByProjectAndUserIDParams) ([]database.Workspace, error) { + workspaces := make([]database.Workspace, 0) + for _, workspace := range q.workspace { + if workspace.OwnerID != arg.OwnerID { + continue + } + if workspace.ProjectID.String() != arg.ProjectID.String() { + continue + } + workspaces = append(workspaces, workspace) + } + if len(workspaces) == 0 { + return nil, sql.ErrNoRows + } + return workspaces, nil +} + +func (q *fakeQuerier) GetWorkspacesByUserID(_ context.Context, ownerID string) ([]database.Workspace, error) { + workspaces := make([]database.Workspace, 0) + for _, workspace := range q.workspace { + if workspace.OwnerID != ownerID { + continue + } + workspaces = append(workspaces, workspace) + } + if len(workspaces) == 0 { + return nil, sql.ErrNoRows + } + return workspaces, nil +} + func (q *fakeQuerier) GetOrganizationByName(_ context.Context, name string) (database.Organization, error) { for _, organization := range q.organizations { if organization.Name == name { @@ -102,6 +207,15 @@ func (q *fakeQuerier) GetOrganizationsByUserID(_ context.Context, userID string) return organizations, nil } +func (q *fakeQuerier) GetProjectByID(_ context.Context, id uuid.UUID) (database.Project, error) { + for _, project := range q.project { + if project.ID.String() == id.String() { + return project, nil + } + } + return database.Project{}, sql.ErrNoRows +} + func (q *fakeQuerier) GetProjectByOrganizationAndName(_ context.Context, arg database.GetProjectByOrganizationAndNameParams) (database.Project, error) { for _, project := range q.project { if project.OrganizationID != arg.OrganizationID { @@ -129,6 +243,16 @@ func (q *fakeQuerier) GetProjectHistoryByProjectID(_ context.Context, projectID return history, nil } +func (q *fakeQuerier) GetProjectHistoryByID(_ context.Context, projectHistoryID uuid.UUID) (database.ProjectHistory, error) { + for _, projectHistory := range q.projectHistory { + if projectHistory.ID.String() != projectHistoryID.String() { + continue + } + return projectHistory, nil + } + return database.ProjectHistory{}, sql.ErrNoRows +} + func (q *fakeQuerier) GetProjectsByOrganizationIDs(_ context.Context, ids []string) ([]database.Project, error) { projects := make([]database.Project, 0) for _, project := range q.project { @@ -273,6 +397,63 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam return user, nil } +func (q *fakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWorkspaceParams) (database.Workspace, error) { + //nolint:gosimple + workspace := database.Workspace{ + ID: arg.ID, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + OwnerID: arg.OwnerID, + ProjectID: arg.ProjectID, + Name: arg.Name, + } + q.workspace = append(q.workspace, workspace) + return workspace, nil +} + +func (q *fakeQuerier) InsertWorkspaceAgent(_ context.Context, arg database.InsertWorkspaceAgentParams) (database.WorkspaceAgent, error) { + //nolint:gosimple + workspaceAgent := database.WorkspaceAgent{ + ID: arg.ID, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + WorkspaceResourceID: arg.WorkspaceResourceID, + InstanceMetadata: arg.InstanceMetadata, + ResourceMetadata: arg.ResourceMetadata, + } + q.workspaceAgent = append(q.workspaceAgent, workspaceAgent) + return workspaceAgent, nil +} + +func (q *fakeQuerier) InsertWorkspaceHistory(_ context.Context, arg database.InsertWorkspaceHistoryParams) (database.WorkspaceHistory, error) { + workspaceHistory := database.WorkspaceHistory{ + ID: arg.ID, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + WorkspaceID: arg.WorkspaceID, + ProjectHistoryID: arg.ProjectHistoryID, + BeforeID: arg.BeforeID, + Transition: arg.Transition, + Initiator: arg.Initiator, + ProvisionJobID: arg.ProvisionJobID, + } + q.workspaceHistory = append(q.workspaceHistory, workspaceHistory) + return workspaceHistory, nil +} + +func (q *fakeQuerier) InsertWorkspaceResource(_ context.Context, arg database.InsertWorkspaceResourceParams) (database.WorkspaceResource, error) { + workspaceResource := database.WorkspaceResource{ + ID: arg.ID, + CreatedAt: arg.CreatedAt, + WorkspaceHistoryID: arg.WorkspaceHistoryID, + Type: arg.Type, + Name: arg.Name, + WorkspaceAgentToken: arg.WorkspaceAgentToken, + } + q.workspaceResource = append(q.workspaceResource, workspaceResource) + return workspaceResource, nil +} + func (q *fakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPIKeyByIDParams) error { for index, apiKey := range q.apiKeys { if apiKey.ID != arg.ID { @@ -288,3 +469,16 @@ func (q *fakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPI } return sql.ErrNoRows } + +func (q *fakeQuerier) UpdateWorkspaceHistoryByID(_ context.Context, arg database.UpdateWorkspaceHistoryByIDParams) error { + for index, workspaceHistory := range q.workspaceHistory { + if workspaceHistory.ID.String() != arg.ID.String() { + continue + } + workspaceHistory.UpdatedAt = arg.UpdatedAt + workspaceHistory.AfterID = arg.AfterID + q.workspaceHistory[index] = workspaceHistory + return nil + } + return sql.ErrNoRows +} diff --git a/database/dump.sql b/database/dump.sql index 5e8a1bf7cb3e9..9ba40007c5285 100644 --- a/database/dump.sql +++ b/database/dump.sql @@ -1,5 +1,14 @@ -- Code generated by 'make database/generate'. DO NOT EDIT. +CREATE TYPE log_level AS ENUM ( + 'trace', + 'debug', + 'info', + 'warn', + 'error', + 'fatal' +); + CREATE TYPE login_type AS ENUM ( 'built-in', 'saml', @@ -25,6 +34,13 @@ CREATE TYPE userstatus AS ENUM ( 'decommissioned' ); +CREATE TYPE workspace_transition AS ENUM ( + 'create', + 'start', + 'stop', + 'delete' +); + CREATE TABLE api_keys ( id text NOT NULL, hashed_secret bytea NOT NULL, @@ -132,6 +148,58 @@ CREATE TABLE users ( shell text DEFAULT ''::text NOT NULL ); +CREATE TABLE workspace ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + owner_id text NOT NULL, + project_id uuid NOT NULL, + name character varying(64) NOT NULL +); + +CREATE TABLE workspace_agent ( + id uuid NOT NULL, + workspace_resource_id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + instance_metadata jsonb NOT NULL, + resource_metadata jsonb NOT NULL +); + +CREATE TABLE workspace_history ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + completed_at timestamp with time zone, + workspace_id uuid NOT NULL, + project_history_id uuid NOT NULL, + before_id uuid, + after_id uuid, + transition workspace_transition NOT NULL, + initiator character varying(255) NOT NULL, + provisioner_state bytea, + provision_job_id uuid NOT NULL +); + +CREATE TABLE workspace_log ( + workspace_id uuid NOT NULL, + workspace_history_id uuid NOT NULL, + created timestamp with time zone NOT NULL, + logged_by character varying(255), + level log_level NOT NULL, + log jsonb NOT NULL +); + +CREATE TABLE workspace_resource ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + workspace_history_id uuid NOT NULL, + type character varying(256) NOT NULL, + name character varying(64) NOT NULL, + workspace_agent_token character varying(128) NOT NULL, + workspace_agent_id uuid +); + ALTER TABLE ONLY project_history ADD CONSTRAINT project_history_id_key UNIQUE (id); @@ -150,9 +218,50 @@ ALTER TABLE ONLY project_parameter ALTER TABLE ONLY project_parameter ADD CONSTRAINT project_parameter_project_history_id_name_key UNIQUE (project_history_id, name); +ALTER TABLE ONLY workspace_agent + ADD CONSTRAINT workspace_agent_id_key UNIQUE (id); + +ALTER TABLE ONLY workspace_history + ADD CONSTRAINT workspace_history_id_key UNIQUE (id); + +ALTER TABLE ONLY workspace + ADD CONSTRAINT workspace_id_key UNIQUE (id); + +ALTER TABLE ONLY workspace_resource + ADD CONSTRAINT workspace_resource_id_key UNIQUE (id); + +ALTER TABLE ONLY workspace_resource + ADD CONSTRAINT workspace_resource_workspace_agent_token_key UNIQUE (workspace_agent_token); + +ALTER TABLE ONLY workspace_resource + ADD CONSTRAINT workspace_resource_workspace_history_id_name_key UNIQUE (workspace_history_id, name); + +CREATE INDEX workspace_log_index ON workspace_log USING btree (workspace_id, workspace_history_id); + ALTER TABLE ONLY project_history ADD CONSTRAINT project_history_project_id_fkey FOREIGN KEY (project_id) REFERENCES project(id); ALTER TABLE ONLY project_parameter ADD CONSTRAINT project_parameter_project_history_id_fkey FOREIGN KEY (project_history_id) REFERENCES project_history(id) ON DELETE CASCADE; +ALTER TABLE ONLY workspace_agent + ADD CONSTRAINT workspace_agent_workspace_resource_id_fkey FOREIGN KEY (workspace_resource_id) REFERENCES workspace_resource(id) ON DELETE CASCADE; + +ALTER TABLE ONLY workspace_history + ADD CONSTRAINT workspace_history_project_history_id_fkey FOREIGN KEY (project_history_id) REFERENCES project_history(id) ON DELETE CASCADE; + +ALTER TABLE ONLY workspace_history + ADD CONSTRAINT workspace_history_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE; + +ALTER TABLE ONLY workspace_log + ADD CONSTRAINT workspace_log_workspace_history_id_fkey FOREIGN KEY (workspace_history_id) REFERENCES workspace_history(id) ON DELETE CASCADE; + +ALTER TABLE ONLY workspace_log + ADD CONSTRAINT workspace_log_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE; + +ALTER TABLE ONLY workspace + ADD CONSTRAINT workspace_project_id_fkey FOREIGN KEY (project_id) REFERENCES project(id); + +ALTER TABLE ONLY workspace_resource + ADD CONSTRAINT workspace_resource_workspace_history_id_fkey FOREIGN KEY (workspace_history_id) REFERENCES workspace_history(id) ON DELETE CASCADE; + diff --git a/database/migrations/000003_workspaces.down.sql b/database/migrations/000003_workspaces.down.sql new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/database/migrations/000003_workspaces.up.sql b/database/migrations/000003_workspaces.up.sql new file mode 100644 index 0000000000000..55b6150815723 --- /dev/null +++ b/database/migrations/000003_workspaces.up.sql @@ -0,0 +1,90 @@ +CREATE TABLE workspace ( + id uuid NOT NULL UNIQUE, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + owner_id text NOT NULL, + project_id uuid NOT NULL REFERENCES project (id), + name varchar(64) NOT NULL +); + +CREATE TYPE workspace_transition AS ENUM ( + 'create', + 'start', + 'stop', + 'delete' +); + +-- Workspace transition represents a change in workspace state. +CREATE TABLE workspace_history ( + id uuid NOT NULL UNIQUE, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + completed_at timestamptz, + workspace_id uuid NOT NULL REFERENCES workspace (id) ON DELETE CASCADE, + project_history_id uuid NOT NULL REFERENCES project_history (id) ON DELETE CASCADE, + before_id uuid, + after_id uuid, + transition workspace_transition NOT NULL, + initiator varchar(255) NOT NULL, + -- State stored by the provisioner + provisioner_state bytea, + -- Job ID of the action + provision_job_id uuid NOT NULL +); + +-- Cloud resources produced by a provision job. +CREATE TABLE workspace_resource ( + id uuid NOT NULL UNIQUE, + created_at timestamptz NOT NULL, + workspace_history_id uuid NOT NULL REFERENCES workspace_history (id) ON DELETE CASCADE, + -- Resource type produced by a provisioner. + -- eg. "google_compute_instance" + type varchar(256) NOT NULL, + -- Name of the resource. + -- eg. "kyle-dev-instance" + name varchar(64) NOT NULL, + -- Token for an agent to connect. + workspace_agent_token varchar(128) NOT NULL UNIQUE, + -- If an agent has been conencted for this resource, + -- the agent table is not null. + workspace_agent_id uuid, + + UNIQUE(workspace_history_id, name) +); + +CREATE TABLE workspace_agent ( + id uuid NOT NULL UNIQUE, + workspace_resource_id uuid NOT NULL REFERENCES workspace_resource (id) ON DELETE CASCADE, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + -- Identifies instance architecture, cloud, etc. + instance_metadata jsonb NOT NULL, + -- Identifies resources. + resource_metadata jsonb NOT NULL +); + +CREATE TYPE log_level AS ENUM ( + 'trace', + 'debug', + 'info', + 'warn', + 'error', + 'fatal' +); + +CREATE TABLE workspace_log ( + workspace_id uuid NOT NULL REFERENCES workspace (id) ON DELETE CASCADE, + -- workspace_history_id can be NULL because some events are not going to be part of a + -- deliberate transition, e.g. an infrastructure failure that kills the workspace + workspace_history_id uuid NOT NULL REFERENCES workspace_history (id) ON DELETE CASCADE, + created timestamptz NOT NULL, +-- not sure this is necessary, also not sure it's necessary separate from the log column + logged_by varchar(255), + level log_level NOT NULL, + log jsonb NOT NULL +); + +CREATE INDEX workspace_log_index ON workspace_log ( + workspace_id, + workspace_history_id +); \ No newline at end of file diff --git a/database/models.go b/database/models.go index 341e7821ec8a8..58f0e05f28b9f 100644 --- a/database/models.go +++ b/database/models.go @@ -11,6 +11,29 @@ import ( "github.com/google/uuid" ) +type LogLevel string + +const ( + LogLevelTrace LogLevel = "trace" + LogLevelDebug LogLevel = "debug" + LogLevelInfo LogLevel = "info" + LogLevelWarn LogLevel = "warn" + LogLevelError LogLevel = "error" + LogLevelFatal LogLevel = "fatal" +) + +func (e *LogLevel) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = LogLevel(s) + case string: + *e = LogLevel(s) + default: + return fmt.Errorf("unsupported scan type for LogLevel: %T", src) + } + return nil +} + type LoginType string const ( @@ -106,6 +129,27 @@ func (e *UserStatus) Scan(src interface{}) error { return nil } +type WorkspaceTransition string + +const ( + WorkspaceTransitionCreate WorkspaceTransition = "create" + WorkspaceTransitionStart WorkspaceTransition = "start" + WorkspaceTransitionStop WorkspaceTransition = "stop" + WorkspaceTransitionDelete WorkspaceTransition = "delete" +) + +func (e *WorkspaceTransition) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = WorkspaceTransition(s) + case string: + *e = WorkspaceTransition(s) + default: + return fmt.Errorf("unsupported scan type for WorkspaceTransition: %T", src) + } + return nil +} + type APIKey struct { ID string `db:"id" json:"id"` HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` @@ -212,3 +256,55 @@ type User struct { Decomissioned bool `db:"_decomissioned" json:"_decomissioned"` Shell string `db:"shell" json:"shell"` } + +type Workspace struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OwnerID string `db:"owner_id" json:"owner_id"` + ProjectID uuid.UUID `db:"project_id" json:"project_id"` + Name string `db:"name" json:"name"` +} + +type WorkspaceAgent struct { + ID uuid.UUID `db:"id" json:"id"` + WorkspaceResourceID uuid.UUID `db:"workspace_resource_id" json:"workspace_resource_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + InstanceMetadata json.RawMessage `db:"instance_metadata" json:"instance_metadata"` + ResourceMetadata json.RawMessage `db:"resource_metadata" json:"resource_metadata"` +} + +type WorkspaceHistory struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + CompletedAt sql.NullTime `db:"completed_at" json:"completed_at"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + ProjectHistoryID uuid.UUID `db:"project_history_id" json:"project_history_id"` + BeforeID uuid.NullUUID `db:"before_id" json:"before_id"` + AfterID uuid.NullUUID `db:"after_id" json:"after_id"` + Transition WorkspaceTransition `db:"transition" json:"transition"` + Initiator string `db:"initiator" json:"initiator"` + ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"` + ProvisionJobID uuid.UUID `db:"provision_job_id" json:"provision_job_id"` +} + +type WorkspaceLog struct { + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + WorkspaceHistoryID uuid.UUID `db:"workspace_history_id" json:"workspace_history_id"` + Created time.Time `db:"created" json:"created"` + LoggedBy sql.NullString `db:"logged_by" json:"logged_by"` + Level LogLevel `db:"level" json:"level"` + Log json.RawMessage `db:"log" json:"log"` +} + +type WorkspaceResource struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + WorkspaceHistoryID uuid.UUID `db:"workspace_history_id" json:"workspace_history_id"` + Type string `db:"type" json:"type"` + Name string `db:"name" json:"name"` + WorkspaceAgentToken string `db:"workspace_agent_token" json:"workspace_agent_token"` + WorkspaceAgentID uuid.NullUUID `db:"workspace_agent_id" json:"workspace_agent_id"` +} diff --git a/database/querier.go b/database/querier.go index 255d55faceba4..64b26cbdaf4da 100644 --- a/database/querier.go +++ b/database/querier.go @@ -13,12 +13,21 @@ type querier interface { GetOrganizationByName(ctx context.Context, name string) (Organization, error) GetOrganizationMemberByUserID(ctx context.Context, arg GetOrganizationMemberByUserIDParams) (OrganizationMember, error) GetOrganizationsByUserID(ctx context.Context, userID string) ([]Organization, error) + GetProjectByID(ctx context.Context, id uuid.UUID) (Project, error) GetProjectByOrganizationAndName(ctx context.Context, arg GetProjectByOrganizationAndNameParams) (Project, error) + GetProjectHistoryByID(ctx context.Context, id uuid.UUID) (ProjectHistory, error) GetProjectHistoryByProjectID(ctx context.Context, projectID uuid.UUID) ([]ProjectHistory, error) GetProjectsByOrganizationIDs(ctx context.Context, ids []string) ([]Project, error) GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id string) (User, error) GetUserCount(ctx context.Context) (int64, error) + GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) + GetWorkspaceByUserIDAndName(ctx context.Context, arg GetWorkspaceByUserIDAndNameParams) (Workspace, error) + GetWorkspaceHistoryByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceHistory, error) + GetWorkspaceHistoryByWorkspaceIDWithoutAfter(ctx context.Context, workspaceID uuid.UUID) (WorkspaceHistory, error) + GetWorkspaceResourcesByHistoryID(ctx context.Context, workspaceHistoryID uuid.UUID) ([]WorkspaceResource, error) + GetWorkspacesByProjectAndUserID(ctx context.Context, arg GetWorkspacesByProjectAndUserIDParams) ([]Workspace, error) + GetWorkspacesByUserID(ctx context.Context, ownerID string) ([]Workspace, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) @@ -26,7 +35,12 @@ type querier interface { InsertProjectHistory(ctx context.Context, arg InsertProjectHistoryParams) (ProjectHistory, error) InsertProjectParameter(ctx context.Context, arg InsertProjectParameterParams) (ProjectParameter, error) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) + InsertWorkspace(ctx context.Context, arg InsertWorkspaceParams) (Workspace, error) + InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspaceAgentParams) (WorkspaceAgent, error) + InsertWorkspaceHistory(ctx context.Context, arg InsertWorkspaceHistoryParams) (WorkspaceHistory, error) + InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error + UpdateWorkspaceHistoryByID(ctx context.Context, arg UpdateWorkspaceHistoryByIDParams) error } var _ querier = (*sqlQuerier)(nil) diff --git a/database/query.sql b/database/query.sql index 74ab452b61392..6ed73a070edcd 100644 --- a/database/query.sql +++ b/database/query.sql @@ -30,8 +30,8 @@ SELECT FROM users WHERE - username = $1 - OR email = $2 + LOWER(username) = LOWER(@username) + OR email = @email LIMIT 1; @@ -47,7 +47,7 @@ SELECT FROM organizations WHERE - name = $1 + LOWER(name) = LOWER(@name) LIMIT 1; @@ -77,14 +77,24 @@ WHERE LIMIT 1; +-- name: GetProjectByID :one +SELECT + * +FROM + project +WHERE + id = $1 +LIMIT + 1; + -- name: GetProjectByOrganizationAndName :one SELECT * FROM project WHERE - organization_id = $1 - AND name = $2 + organization_id = @organization_id + AND LOWER(name) = LOWER(@name) LIMIT 1; @@ -104,6 +114,75 @@ FROM WHERE project_id = $1; +-- name: GetProjectHistoryByID :one +SELECT + * +FROM + project_history +WHERE + id = $1; + +-- name: GetWorkspacesByUserID :many +SELECT + * +FROM + workspace +WHERE + owner_id = $1; + +-- name: GetWorkspaceByUserIDAndName :one +SELECT + * +FROM + workspace +WHERE + owner_id = @owner_id + AND LOWER(name) = LOWER(@name); + +-- name: GetWorkspacesByProjectAndUserID :many +SELECT + * +FROM + workspace +WHERE + owner_id = $1 + AND project_id = $2; + +-- name: GetWorkspaceHistoryByWorkspaceID :many +SELECT + * +FROM + workspace_history +WHERE + workspace_id = $1; + +-- name: GetWorkspaceHistoryByWorkspaceIDWithoutAfter :one +SELECT + * +FROM + workspace_history +WHERE + workspace_id = $1 + AND after_id IS NULL +LIMIT + 1; + +-- name: GetWorkspaceResourcesByHistoryID :many +SELECT + * +FROM + workspace_resource +WHERE + workspace_history_id = $1; + +-- name: GetWorkspaceAgentsByResourceIDs :many +SELECT + * +FROM + workspace_agent +WHERE + workspace_resource_id = ANY(@ids :: uuid [ ]); + -- name: InsertAPIKey :one INSERT INTO api_keys ( @@ -243,6 +322,61 @@ INSERT INTO VALUES ($1, $2, $3, $4, false, $5, $6, $7, $8) RETURNING *; +-- name: InsertWorkspace :one +INSERT INTO + workspace ( + id, + created_at, + updated_at, + owner_id, + project_id, + name + ) +VALUES + ($1, $2, $3, $4, $5, $6) RETURNING *; + +-- name: InsertWorkspaceAgent :one +INSERT INTO + workspace_agent ( + id, + workspace_resource_id, + created_at, + updated_at, + instance_metadata, + resource_metadata + ) +VALUES + ($1, $2, $3, $4, $5, $6) RETURNING *; + +-- name: InsertWorkspaceHistory :one +INSERT INTO + workspace_history ( + id, + created_at, + updated_at, + workspace_id, + project_history_id, + before_id, + transition, + initiator, + provision_job_id + ) +VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *; + +-- name: InsertWorkspaceResource :one +INSERT INTO + workspace_resource ( + id, + created_at, + workspace_history_id, + type, + name, + workspace_agent_token + ) +VALUES + ($1, $2, $3, $4, $5, $6) RETURNING *; + -- name: UpdateAPIKeyByID :exec UPDATE api_keys @@ -254,3 +388,12 @@ SET oidc_expiry = $6 WHERE id = $1; + +-- name: UpdateWorkspaceHistoryByID :exec +UPDATE + workspace_history +SET + updated_at = $2, + after_id = $3 +WHERE + id = $1; diff --git a/database/query.sql.go b/database/query.sql.go index 096baad8bbed1..abb5aea348521 100644 --- a/database/query.sql.go +++ b/database/query.sql.go @@ -6,6 +6,7 @@ package database import ( "context" "database/sql" + "encoding/json" "time" "github.com/google/uuid" @@ -52,7 +53,7 @@ SELECT FROM organizations WHERE - name = $1 + LOWER(name) = LOWER($1) LIMIT 1 ` @@ -155,6 +156,32 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, userID string return items, nil } +const getProjectByID = `-- name: GetProjectByID :one +SELECT + id, created_at, updated_at, organization_id, name, provisioner, active_version_id +FROM + project +WHERE + id = $1 +LIMIT + 1 +` + +func (q *sqlQuerier) GetProjectByID(ctx context.Context, id uuid.UUID) (Project, error) { + row := q.db.QueryRowContext(ctx, getProjectByID, id) + var i Project + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OrganizationID, + &i.Name, + &i.Provisioner, + &i.ActiveVersionID, + ) + return i, err +} + const getProjectByOrganizationAndName = `-- name: GetProjectByOrganizationAndName :one SELECT id, created_at, updated_at, organization_id, name, provisioner, active_version_id @@ -162,7 +189,7 @@ FROM project WHERE organization_id = $1 - AND name = $2 + AND LOWER(name) = LOWER($2) LIMIT 1 ` @@ -187,6 +214,32 @@ func (q *sqlQuerier) GetProjectByOrganizationAndName(ctx context.Context, arg Ge return i, err } +const getProjectHistoryByID = `-- name: GetProjectHistoryByID :one +SELECT + id, project_id, created_at, updated_at, name, description, storage_method, storage_source, import_job_id +FROM + project_history +WHERE + id = $1 +` + +func (q *sqlQuerier) GetProjectHistoryByID(ctx context.Context, id uuid.UUID) (ProjectHistory, error) { + row := q.db.QueryRowContext(ctx, getProjectHistoryByID, id) + var i ProjectHistory + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.Description, + &i.StorageMethod, + &i.StorageSource, + &i.ImportJobID, + ) + return i, err +} + const getProjectHistoryByProjectID = `-- name: GetProjectHistoryByProjectID :many SELECT id, project_id, created_at, updated_at, name, description, storage_method, storage_source, import_job_id @@ -275,7 +328,7 @@ SELECT FROM users WHERE - username = $1 + LOWER(username) = LOWER($1) OR email = $2 LIMIT 1 @@ -365,6 +418,275 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) { return count, err } +const getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many +SELECT + id, workspace_resource_id, created_at, updated_at, instance_metadata, resource_metadata +FROM + workspace_agent +WHERE + workspace_resource_id = ANY($1 :: uuid [ ]) +` + +func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAgentsByResourceIDs, pq.Array(ids)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgent + for rows.Next() { + var i WorkspaceAgent + if err := rows.Scan( + &i.ID, + &i.WorkspaceResourceID, + &i.CreatedAt, + &i.UpdatedAt, + &i.InstanceMetadata, + &i.ResourceMetadata, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getWorkspaceByUserIDAndName = `-- name: GetWorkspaceByUserIDAndName :one +SELECT + id, created_at, updated_at, owner_id, project_id, name +FROM + workspace +WHERE + owner_id = $1 + AND LOWER(name) = LOWER($2) +` + +type GetWorkspaceByUserIDAndNameParams struct { + OwnerID string `db:"owner_id" json:"owner_id"` + Name string `db:"name" json:"name"` +} + +func (q *sqlQuerier) GetWorkspaceByUserIDAndName(ctx context.Context, arg GetWorkspaceByUserIDAndNameParams) (Workspace, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceByUserIDAndName, arg.OwnerID, arg.Name) + var i Workspace + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.ProjectID, + &i.Name, + ) + return i, err +} + +const getWorkspaceHistoryByWorkspaceID = `-- name: GetWorkspaceHistoryByWorkspaceID :many +SELECT + id, created_at, updated_at, completed_at, workspace_id, project_history_id, before_id, after_id, transition, initiator, provisioner_state, provision_job_id +FROM + workspace_history +WHERE + workspace_id = $1 +` + +func (q *sqlQuerier) GetWorkspaceHistoryByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceHistory, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceHistoryByWorkspaceID, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceHistory + for rows.Next() { + var i WorkspaceHistory + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompletedAt, + &i.WorkspaceID, + &i.ProjectHistoryID, + &i.BeforeID, + &i.AfterID, + &i.Transition, + &i.Initiator, + &i.ProvisionerState, + &i.ProvisionJobID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getWorkspaceHistoryByWorkspaceIDWithoutAfter = `-- name: GetWorkspaceHistoryByWorkspaceIDWithoutAfter :one +SELECT + id, created_at, updated_at, completed_at, workspace_id, project_history_id, before_id, after_id, transition, initiator, provisioner_state, provision_job_id +FROM + workspace_history +WHERE + workspace_id = $1 + AND after_id IS NULL +LIMIT + 1 +` + +func (q *sqlQuerier) GetWorkspaceHistoryByWorkspaceIDWithoutAfter(ctx context.Context, workspaceID uuid.UUID) (WorkspaceHistory, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceHistoryByWorkspaceIDWithoutAfter, workspaceID) + var i WorkspaceHistory + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompletedAt, + &i.WorkspaceID, + &i.ProjectHistoryID, + &i.BeforeID, + &i.AfterID, + &i.Transition, + &i.Initiator, + &i.ProvisionerState, + &i.ProvisionJobID, + ) + return i, err +} + +const getWorkspaceResourcesByHistoryID = `-- name: GetWorkspaceResourcesByHistoryID :many +SELECT + id, created_at, workspace_history_id, type, name, workspace_agent_token, workspace_agent_id +FROM + workspace_resource +WHERE + workspace_history_id = $1 +` + +func (q *sqlQuerier) GetWorkspaceResourcesByHistoryID(ctx context.Context, workspaceHistoryID uuid.UUID) ([]WorkspaceResource, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceResourcesByHistoryID, workspaceHistoryID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceResource + for rows.Next() { + var i WorkspaceResource + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.WorkspaceHistoryID, + &i.Type, + &i.Name, + &i.WorkspaceAgentToken, + &i.WorkspaceAgentID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getWorkspacesByProjectAndUserID = `-- name: GetWorkspacesByProjectAndUserID :many +SELECT + id, created_at, updated_at, owner_id, project_id, name +FROM + workspace +WHERE + owner_id = $1 + AND project_id = $2 +` + +type GetWorkspacesByProjectAndUserIDParams struct { + OwnerID string `db:"owner_id" json:"owner_id"` + ProjectID uuid.UUID `db:"project_id" json:"project_id"` +} + +func (q *sqlQuerier) GetWorkspacesByProjectAndUserID(ctx context.Context, arg GetWorkspacesByProjectAndUserIDParams) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesByProjectAndUserID, arg.OwnerID, arg.ProjectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Workspace + for rows.Next() { + var i Workspace + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.ProjectID, + &i.Name, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getWorkspacesByUserID = `-- name: GetWorkspacesByUserID :many +SELECT + id, created_at, updated_at, owner_id, project_id, name +FROM + workspace +WHERE + owner_id = $1 +` + +func (q *sqlQuerier) GetWorkspacesByUserID(ctx context.Context, ownerID string) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesByUserID, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Workspace + for rows.Next() { + var i Workspace + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.ProjectID, + &i.Name, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertAPIKey = `-- name: InsertAPIKey :one INSERT INTO api_keys ( @@ -801,6 +1123,198 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User return i, err } +const insertWorkspace = `-- name: InsertWorkspace :one +INSERT INTO + workspace ( + id, + created_at, + updated_at, + owner_id, + project_id, + name + ) +VALUES + ($1, $2, $3, $4, $5, $6) RETURNING id, created_at, updated_at, owner_id, project_id, name +` + +type InsertWorkspaceParams struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OwnerID string `db:"owner_id" json:"owner_id"` + ProjectID uuid.UUID `db:"project_id" json:"project_id"` + Name string `db:"name" json:"name"` +} + +func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspaceParams) (Workspace, error) { + row := q.db.QueryRowContext(ctx, insertWorkspace, + arg.ID, + arg.CreatedAt, + arg.UpdatedAt, + arg.OwnerID, + arg.ProjectID, + arg.Name, + ) + var i Workspace + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.ProjectID, + &i.Name, + ) + return i, err +} + +const insertWorkspaceAgent = `-- name: InsertWorkspaceAgent :one +INSERT INTO + workspace_agent ( + id, + workspace_resource_id, + created_at, + updated_at, + instance_metadata, + resource_metadata + ) +VALUES + ($1, $2, $3, $4, $5, $6) RETURNING id, workspace_resource_id, created_at, updated_at, instance_metadata, resource_metadata +` + +type InsertWorkspaceAgentParams struct { + ID uuid.UUID `db:"id" json:"id"` + WorkspaceResourceID uuid.UUID `db:"workspace_resource_id" json:"workspace_resource_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + InstanceMetadata json.RawMessage `db:"instance_metadata" json:"instance_metadata"` + ResourceMetadata json.RawMessage `db:"resource_metadata" json:"resource_metadata"` +} + +func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspaceAgentParams) (WorkspaceAgent, error) { + row := q.db.QueryRowContext(ctx, insertWorkspaceAgent, + arg.ID, + arg.WorkspaceResourceID, + arg.CreatedAt, + arg.UpdatedAt, + arg.InstanceMetadata, + arg.ResourceMetadata, + ) + var i WorkspaceAgent + err := row.Scan( + &i.ID, + &i.WorkspaceResourceID, + &i.CreatedAt, + &i.UpdatedAt, + &i.InstanceMetadata, + &i.ResourceMetadata, + ) + return i, err +} + +const insertWorkspaceHistory = `-- name: InsertWorkspaceHistory :one +INSERT INTO + workspace_history ( + id, + created_at, + updated_at, + workspace_id, + project_history_id, + before_id, + transition, + initiator, + provision_job_id + ) +VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, created_at, updated_at, completed_at, workspace_id, project_history_id, before_id, after_id, transition, initiator, provisioner_state, provision_job_id +` + +type InsertWorkspaceHistoryParams struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + ProjectHistoryID uuid.UUID `db:"project_history_id" json:"project_history_id"` + BeforeID uuid.NullUUID `db:"before_id" json:"before_id"` + Transition WorkspaceTransition `db:"transition" json:"transition"` + Initiator string `db:"initiator" json:"initiator"` + ProvisionJobID uuid.UUID `db:"provision_job_id" json:"provision_job_id"` +} + +func (q *sqlQuerier) InsertWorkspaceHistory(ctx context.Context, arg InsertWorkspaceHistoryParams) (WorkspaceHistory, error) { + row := q.db.QueryRowContext(ctx, insertWorkspaceHistory, + arg.ID, + arg.CreatedAt, + arg.UpdatedAt, + arg.WorkspaceID, + arg.ProjectHistoryID, + arg.BeforeID, + arg.Transition, + arg.Initiator, + arg.ProvisionJobID, + ) + var i WorkspaceHistory + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompletedAt, + &i.WorkspaceID, + &i.ProjectHistoryID, + &i.BeforeID, + &i.AfterID, + &i.Transition, + &i.Initiator, + &i.ProvisionerState, + &i.ProvisionJobID, + ) + return i, err +} + +const insertWorkspaceResource = `-- name: InsertWorkspaceResource :one +INSERT INTO + workspace_resource ( + id, + created_at, + workspace_history_id, + type, + name, + workspace_agent_token + ) +VALUES + ($1, $2, $3, $4, $5, $6) RETURNING id, created_at, workspace_history_id, type, name, workspace_agent_token, workspace_agent_id +` + +type InsertWorkspaceResourceParams struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + WorkspaceHistoryID uuid.UUID `db:"workspace_history_id" json:"workspace_history_id"` + Type string `db:"type" json:"type"` + Name string `db:"name" json:"name"` + WorkspaceAgentToken string `db:"workspace_agent_token" json:"workspace_agent_token"` +} + +func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) { + row := q.db.QueryRowContext(ctx, insertWorkspaceResource, + arg.ID, + arg.CreatedAt, + arg.WorkspaceHistoryID, + arg.Type, + arg.Name, + arg.WorkspaceAgentToken, + ) + var i WorkspaceResource + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.WorkspaceHistoryID, + &i.Type, + &i.Name, + &i.WorkspaceAgentToken, + &i.WorkspaceAgentID, + ) + return i, err +} + const updateAPIKeyByID = `-- name: UpdateAPIKeyByID :exec UPDATE api_keys @@ -834,3 +1348,24 @@ func (q *sqlQuerier) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDP ) return err } + +const updateWorkspaceHistoryByID = `-- name: UpdateWorkspaceHistoryByID :exec +UPDATE + workspace_history +SET + updated_at = $2, + after_id = $3 +WHERE + id = $1 +` + +type UpdateWorkspaceHistoryByIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + AfterID uuid.NullUUID `db:"after_id" json:"after_id"` +} + +func (q *sqlQuerier) UpdateWorkspaceHistoryByID(ctx context.Context, arg UpdateWorkspaceHistoryByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceHistoryByID, arg.ID, arg.UpdatedAt, arg.AfterID) + return err +} diff --git a/httpmw/projectparam.go b/httpmw/projectparam.go index 7daf68130488f..136f013a1af0a 100644 --- a/httpmw/projectparam.go +++ b/httpmw/projectparam.go @@ -15,7 +15,7 @@ import ( type projectParamContextKey struct{} -// ProjectParam returns the project from the ExtractProjectParameter handler. +// ProjectParam returns the project from the ExtractProjectParam handler. func ProjectParam(r *http.Request) database.Project { project, ok := r.Context().Value(projectParamContextKey{}).(database.Project) if !ok { @@ -24,8 +24,8 @@ func ProjectParam(r *http.Request) database.Project { return project } -// ExtractProjectParameter grabs a project from the "project" URL parameter. -func ExtractProjectParameter(db database.Store) func(http.Handler) http.Handler { +// ExtractProjectParam grabs a project from the "project" URL parameter. +func ExtractProjectParam(db database.Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { organization := OrganizationParam(r) diff --git a/httpmw/projectparam_test.go b/httpmw/projectparam_test.go index 129109df50ae6..26fbcfb82783b 100644 --- a/httpmw/projectparam_test.go +++ b/httpmw/projectparam_test.go @@ -86,7 +86,7 @@ func TestProjectParam(t *testing.T) { rtr.Use( httpmw.ExtractAPIKey(db, nil), httpmw.ExtractOrganizationParam(db), - httpmw.ExtractProjectParameter(db), + httpmw.ExtractProjectParam(db), ) rtr.Get("/", nil) r, _ := setupAuthentication(db) @@ -105,7 +105,7 @@ func TestProjectParam(t *testing.T) { rtr.Use( httpmw.ExtractAPIKey(db, nil), httpmw.ExtractOrganizationParam(db), - httpmw.ExtractProjectParameter(db), + httpmw.ExtractProjectParam(db), ) rtr.Get("/", nil) @@ -126,7 +126,7 @@ func TestProjectParam(t *testing.T) { rtr.Use( httpmw.ExtractAPIKey(db, nil), httpmw.ExtractOrganizationParam(db), - httpmw.ExtractProjectParameter(db), + httpmw.ExtractProjectParam(db), ) rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { _ = httpmw.ProjectParam(r) diff --git a/httpmw/userparam.go b/httpmw/userparam.go index 4112f5843a222..f5f15630367fc 100644 --- a/httpmw/userparam.go +++ b/httpmw/userparam.go @@ -33,13 +33,13 @@ func ExtractUserParam(db database.Store) func(http.Handler) http.Handler { }) return } - if userID != "me" { + apiKey := APIKey(r) + if apiKey.UserID != userID && userID != "me" { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ Message: "getting non-personal users isn't supported yet", }) return } - apiKey := APIKey(r) user, err := db.GetUserByID(r.Context(), apiKey.UserID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ diff --git a/httpmw/workspaceparam.go b/httpmw/workspaceparam.go new file mode 100644 index 0000000000000..07d879862b832 --- /dev/null +++ b/httpmw/workspaceparam.go @@ -0,0 +1,60 @@ +package httpmw + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + + "github.com/go-chi/chi" + + "github.com/coder/coder/database" + "github.com/coder/coder/httpapi" +) + +type workspaceParamContextKey struct{} + +// WorkspaceParam returns the workspace from the ExtractWorkspaceParam handler. +func WorkspaceParam(r *http.Request) database.Workspace { + workspace, ok := r.Context().Value(workspaceParamContextKey{}).(database.Workspace) + if !ok { + panic("developer error: workspace param middleware not provided") + } + return workspace +} + +// ExtractWorkspaceParam grabs a workspace from the "workspace" URL parameter. +func ExtractWorkspaceParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + user := UserParam(r) + workspaceName := chi.URLParam(r, "workspace") + if workspaceName == "" { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: "workspace id must be provided", + }) + return + } + workspace, err := db.GetWorkspaceByUserIDAndName(r.Context(), database.GetWorkspaceByUserIDAndNameParams{ + OwnerID: user.ID, + Name: workspaceName, + }) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ + Message: fmt.Sprintf("workspace %q does not exist", workspace), + }) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace: %s", err.Error()), + }) + return + } + + ctx := context.WithValue(r.Context(), workspaceParamContextKey{}, workspace) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/httpmw/workspaceparam_test.go b/httpmw/workspaceparam_test.go new file mode 100644 index 0000000000000..906ffd9de2d47 --- /dev/null +++ b/httpmw/workspaceparam_test.go @@ -0,0 +1,132 @@ +package httpmw_test + +import ( + "context" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cryptorand" + "github.com/coder/coder/database" + "github.com/coder/coder/database/databasefake" + "github.com/coder/coder/httpmw" +) + +func TestWorkspaceParam(t *testing.T) { + t.Parallel() + + setup := func(db database.Store) (*http.Request, database.User) { + var ( + id, secret = randomAPIKeyParts() + hashed = sha256.Sum256([]byte(secret)) + ) + r := httptest.NewRequest("GET", "/", nil) + r.AddCookie(&http.Cookie{ + Name: httpmw.AuthCookie, + Value: fmt.Sprintf("%s-%s", id, secret), + }) + userID, err := cryptorand.String(16) + require.NoError(t, err) + username, err := cryptorand.String(8) + require.NoError(t, err) + user, err := db.InsertUser(r.Context(), database.InsertUserParams{ + ID: userID, + Email: "testaccount@coder.com", + Name: "example", + LoginType: database.LoginTypeBuiltIn, + HashedPassword: hashed[:], + Username: username, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ + ID: id, + UserID: user.ID, + HashedSecret: hashed[:], + LastUsed: database.Now(), + ExpiresAt: database.Now().Add(time.Minute), + }) + require.NoError(t, err) + + ctx := chi.NewRouteContext() + ctx.URLParams.Add("user", "me") + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx)) + return r, user + } + + t.Run("None", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKey(db, nil), + httpmw.ExtractUserParam(db), + httpmw.ExtractWorkspaceParam(db), + ) + rtr.Get("/", nil) + r, _ := setup(db) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKey(db, nil), + httpmw.ExtractUserParam(db), + httpmw.ExtractWorkspaceParam(db), + ) + rtr.Get("/", nil) + r, _ := setup(db) + chi.RouteContext(r.Context()).URLParams.Add("workspace", "frog") + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) + + t.Run("Found", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKey(db, nil), + httpmw.ExtractUserParam(db), + httpmw.ExtractWorkspaceParam(db), + ) + rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { + _ = httpmw.WorkspaceParam(r) + rw.WriteHeader(http.StatusOK) + }) + r, user := setup(db) + workspace, err := db.InsertWorkspace(context.Background(), database.InsertWorkspaceParams{ + ID: uuid.New(), + OwnerID: user.ID, + Name: "hello", + }) + require.NoError(t, err) + chi.RouteContext(r.Context()).URLParams.Add("workspace", workspace.Name) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) +}