feat: Refactor API routes to use UUIDs instead of friendly names (#401)

* Add client for agent

* Cleanup code

* Fix linting error

* Rename routes to be simpler

* Rename workspace history to workspace build

* Refactor HTTP middlewares to use UUIDs

* Cleanup routes

* Compiles!

* Fix files and organizations

* Fix querying

* Fix agent lock

* Cleanup database abstraction

* Add parameters

* Fix linting errors

* Fix log race

* Lock on close wait

* Fix log cleanup

* Fix e2e tests

* Fix upstream version of opencensus-go

* Update coderdtest.go

* Fix coverpkg

* Fix codecov ignore
This commit is contained in:
Kyle Carberry
2022-03-07 11:40:54 -06:00
committed by GitHub
parent 330686f60a
commit bf0ae8f573
115 changed files with 5853 additions and 4657 deletions

View File

@ -2,12 +2,16 @@ package coderd
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/moby/moby/pkg/namesgenerator"
"golang.org/x/xerrors"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
@ -18,160 +22,268 @@ import (
// project versions, and can be updated.
type Workspace database.Workspace
// 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"`
// CreateWorkspaceBuildRequest provides options to update the latest workspace build.
type CreateWorkspaceBuildRequest struct {
ProjectVersionID uuid.UUID `json:"project_version_id" validate:"required"`
Transition database.WorkspaceTransition `json:"transition" validate:"oneof=create start stop delete,required"`
}
// Returns all workspaces across all projects and organizations.
func (api *api) workspaces(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
workspaces, err := api.Database.GetWorkspacesByUserID(r.Context(), apiKey.UserID)
if errors.Is(err, sql.ErrNoRows) {
err = nil
func (*api) workspace(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
render.Status(r, http.StatusOK)
render.JSON(rw, r, convertWorkspace(workspace))
}
func (api *api) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
builds, err := api.Database.GetWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID)
if err != nil {
return
}
jobIDs := make([]uuid.UUID, 0, len(builds))
for _, version := range builds {
jobIDs = append(jobIDs, version.JobID)
}
jobs, err := api.Database.GetProvisionerJobsByIDs(r.Context(), jobIDs)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspaces: %s", err),
Message: fmt.Sprintf("get jobs: %s", err),
})
return
}
apiWorkspaces := make([]Workspace, 0, len(workspaces))
for _, workspace := range workspaces {
apiWorkspaces = append(apiWorkspaces, convertWorkspace(workspace))
jobByID := map[string]database.ProvisionerJob{}
for _, job := range jobs {
jobByID[job.ID.String()] = job
}
apiBuilds := make([]WorkspaceBuild, 0)
for _, build := range builds {
job, exists := jobByID[build.JobID.String()]
if !exists {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("job %q doesn't exist for build %q", build.JobID, build.ID),
})
return
}
apiBuilds = append(apiBuilds, convertWorkspaceBuild(build, convertProvisionerJob(job)))
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, apiWorkspaces)
render.JSON(rw, r, apiBuilds)
}
// Create a new workspace for the currently authenticated user.
func (api *api) postWorkspaceByUser(rw http.ResponseWriter, r *http.Request) {
var createWorkspace CreateWorkspaceRequest
if !httpapi.Read(rw, r, &createWorkspace) {
func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
workspace := httpmw.WorkspaceParam(r)
var createBuild CreateWorkspaceBuildRequest
if !httpapi.Read(rw, r, &createBuild) {
return
}
apiKey := httpmw.APIKey(r)
project, err := api.Database.GetProjectByID(r.Context(), createWorkspace.ProjectID)
projectVersion, err := api.Database.GetProjectVersionByID(r.Context(), createBuild.ProjectVersionID)
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: fmt.Sprintf("project %q doesn't exist", createWorkspace.ProjectID.String()),
Message: "project version not found",
Errors: []httpapi.Error{{
Field: "project_id",
Code: "not_found",
Field: "project_version_id",
Code: "exists",
}},
})
return
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get project version: %s", err),
})
return
}
projectVersionJob, err := api.Database.GetProvisionerJobByID(r.Context(), projectVersion.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get provisioner job: %s", err),
})
return
}
projectVersionJobStatus := convertProvisionerJob(projectVersionJob).Status
switch projectVersionJobStatus {
case ProvisionerJobPending, ProvisionerJobRunning:
httpapi.Write(rw, http.StatusNotAcceptable, httpapi.Response{
Message: fmt.Sprintf("The provided project version is %s. Wait for it to complete importing!", projectVersionJobStatus),
})
return
case ProvisionerJobFailed:
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
Message: fmt.Sprintf("The provided project version %q has failed to import. You cannot create workspaces using it!", projectVersion.Name),
})
return
case ProvisionerJobCancelled:
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
Message: "The provided project version was canceled during import. You cannot create workspaces using it!",
})
return
}
project, err := api.Database.GetProjectByID(r.Context(), projectVersion.ProjectID.UUID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get project: %s", err),
})
return
}
_, err = api.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 := api.Database.GetWorkspaceByUserIDAndName(r.Context(), database.GetWorkspaceByUserIDAndNameParams{
OwnerID: apiKey.UserID,
Name: createWorkspace.Name,
})
// Store prior history ID if it exists to update it after we create new!
priorHistoryID := uuid.NullUUID{}
priorHistory, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID)
if err == nil {
// If the workspace already exists, don't allow creation.
project, err := api.Database.GetProjectByID(r.Context(), workspace.ProjectID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("find project for conflicting workspace name %q: %s", createWorkspace.Name, err),
priorJob, err := api.Database.GetProvisionerJobByID(r.Context(), priorHistory.JobID)
if err == nil && !priorJob.CompletedAt.Valid {
httpapi.Write(rw, http.StatusConflict, httpapi.Response{
Message: "a workspace build is already active",
})
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) {
priorHistoryID = uuid.NullUUID{
UUID: priorHistory.ID,
Valid: true,
}
} else if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspace by name: %s", err.Error()),
Message: fmt.Sprintf("get prior workspace build: %s", err),
})
return
}
// Workspaces are created without any versions.
workspace, err = api.Database.InsertWorkspace(r.Context(), database.InsertWorkspaceParams{
ID: uuid.New(),
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
OwnerID: apiKey.UserID,
ProjectID: project.ID,
Name: createWorkspace.Name,
var workspaceBuild database.WorkspaceBuild
var provisionerJob database.ProvisionerJob
// This must happen in a transaction to ensure history can be inserted, and
// the prior history can update it's "after" column to point at the new.
err = api.Database.InTx(func(db database.Store) error {
workspaceBuildID := uuid.New()
input, err := json.Marshal(workspaceProvisionJob{
WorkspaceBuildID: workspaceBuildID,
})
if err != nil {
return xerrors.Errorf("marshal provision job: %w", err)
}
provisionerJob, err = db.InsertProvisionerJob(r.Context(), database.InsertProvisionerJobParams{
ID: uuid.New(),
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
InitiatorID: apiKey.UserID,
OrganizationID: project.OrganizationID,
Provisioner: project.Provisioner,
Type: database.ProvisionerJobTypeWorkspaceBuild,
StorageMethod: projectVersionJob.StorageMethod,
StorageSource: projectVersionJob.StorageSource,
Input: input,
})
if err != nil {
return xerrors.Errorf("insert provisioner job: %w", err)
}
workspaceBuild, err = db.InsertWorkspaceBuild(r.Context(), database.InsertWorkspaceBuildParams{
ID: workspaceBuildID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
WorkspaceID: workspace.ID,
ProjectVersionID: projectVersion.ID,
BeforeID: priorHistoryID,
Name: namesgenerator.GetRandomName(1),
Initiator: apiKey.UserID,
Transition: createBuild.Transition,
JobID: provisionerJob.ID,
})
if err != nil {
return xerrors.Errorf("insert workspace build: %w", err)
}
if priorHistoryID.Valid {
// Update the prior history entries "after" column.
err = db.UpdateWorkspaceBuildByID(r.Context(), database.UpdateWorkspaceBuildByIDParams{
ID: priorHistory.ID,
ProvisionerState: priorHistory.ProvisionerState,
UpdatedAt: database.Now(),
AfterID: uuid.NullUUID{
UUID: workspaceBuild.ID,
Valid: true,
},
})
if err != nil {
return xerrors.Errorf("update prior workspace build: %w", err)
}
}
return nil
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("insert workspace: %s", err),
Message: err.Error(),
})
return
}
render.Status(r, http.StatusCreated)
render.JSON(rw, r, convertWorkspace(workspace))
render.JSON(rw, r, convertWorkspaceBuild(workspaceBuild, convertProvisionerJob(provisionerJob)))
}
// Returns a single workspace.
func (*api) workspaceByUser(rw http.ResponseWriter, r *http.Request) {
func (api *api) workspaceBuildLatest(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
render.Status(r, http.StatusOK)
render.JSON(rw, r, convertWorkspace(workspace))
}
// Returns all workspaces for a specific project.
func (api *api) workspacesByProject(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
project := httpmw.ProjectParam(r)
workspaces, err := api.Database.GetWorkspacesByProjectAndUserID(r.Context(), database.GetWorkspacesByProjectAndUserIDParams{
OwnerID: apiKey.UserID,
ProjectID: project.ID,
})
workspaceBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID)
if errors.Is(err, sql.ErrNoRows) {
err = nil
httpapi.Write(rw, http.StatusNotFound, httpapi.Response{
Message: "no workspace build found",
})
return
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspaces: %s", err),
Message: fmt.Sprintf("get workspace build by name: %s", err),
})
return
}
job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get provisioner job: %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)
render.JSON(rw, r, convertWorkspaceBuild(workspaceBuild, convertProvisionerJob(job)))
}
func (api *api) workspaceBuildByName(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
workspaceBuildName := chi.URLParam(r, "workspacebuildname")
workspaceBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDAndName(r.Context(), database.GetWorkspaceBuildByWorkspaceIDAndNameParams{
WorkspaceID: workspace.ID,
Name: workspaceBuildName,
})
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusNotFound, httpapi.Response{
Message: fmt.Sprintf("no workspace build found by name %q", workspaceBuildName),
})
return
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspace build by name: %s", err),
})
return
}
job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get provisioner job: %s", err),
})
return
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, convertWorkspaceBuild(workspaceBuild, convertProvisionerJob(job)))
}
// Converts the internal workspace representation to a public external-facing model.
func convertWorkspace(workspace database.Workspace) Workspace {
return Workspace(workspace)
}