feat: Add project API endpoints (#51)

* feat: Add project models

* Add project query functions

* Add organization parameter query

* Add project URL parameter parse

* Add project create and list endpoints

* Add test for organization provided

* Remove unimplemented routes

* Decrease conn timeout

* Add test for UnbiasedModulo32

* Fix expected value

* Add single user endpoint

* Add query for project versions

* Fix linting errors

* Add comments

* Add test for invalid archive

* Check unauthenticated endpoints

* Add check if no change happened

* Ensure context close ends listener

* Fix parallel test run

* Test empty

* Fix organization param comment
This commit is contained in:
Kyle Carberry
2022-01-24 11:07:42 -06:00
committed by GitHub
parent 52e50fc9ca
commit a44056cff5
37 changed files with 2121 additions and 22 deletions

View File

@ -10,6 +10,7 @@ import (
)
func TestRoot(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithCancel(context.Background())
go cancelFunc()
err := cmd.Root().ExecuteContext(ctx)

View File

@ -20,6 +20,9 @@ type Options struct {
// New constructs the Coder API into an HTTP handler.
func New(options *Options) http.Handler {
projects := &projects{
Database: options.Database,
}
users := &users{
Database: options.Database,
}
@ -44,6 +47,25 @@ func New(options *Options) http.Handler {
r.Get("/{user}/organizations", users.userOrganizations)
})
})
r.Route("/projects", func(r chi.Router) {
r.Use(
httpmw.ExtractAPIKey(options.Database, nil),
)
r.Get("/", projects.allProjects)
r.Route("/{organization}", func(r chi.Router) {
r.Use(httpmw.ExtractOrganizationParam(options.Database))
r.Get("/", projects.allProjectsForOrganization)
r.Post("/", projects.createProject)
r.Route("/{project}", func(r chi.Router) {
r.Use(httpmw.ExtractProjectParameter(options.Database))
r.Get("/", projects.project)
r.Route("/versions", func(r chi.Router) {
r.Get("/", projects.projectVersions)
r.Post("/", projects.createProjectVersion)
})
})
})
})
})
r.NotFound(site.Handler().ServeHTTP)
return r

View File

@ -13,6 +13,7 @@ func TestMain(m *testing.M) {
}
func TestNew(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_ = server.RandomInitialUser(t)
}

229
coderd/projects.go Normal file
View File

@ -0,0 +1,229 @@
package coderd
import (
"archive/tar"
"bytes"
"database/sql"
"errors"
"fmt"
"net/http"
"time"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/moby/moby/pkg/namesgenerator"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/httpmw"
)
// Project is the JSON representation of a Coder project.
// This type matches the database object for now, but is
// abstracted for ease of change later on.
type Project database.Project
// ProjectVersion is the JSON representation of a Coder project version.
type ProjectVersion struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Name string `json:"name"`
StorageMethod database.ProjectStorageMethod `json:"storage_method"`
}
// CreateProjectRequest enables callers to create a new Project.
type CreateProjectRequest struct {
Name string `json:"name" validate:"username,required"`
Provisioner database.ProvisionerType `json:"provisioner" validate:"oneof=terraform cdr-basic,required"`
}
// CreateProjectVersionRequest enables callers to create a new Project Version.
type CreateProjectVersionRequest struct {
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"`
}
type projects struct {
Database database.Store
}
// allProjects lists all projects across organizations for a user.
func (p *projects) allProjects(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
organizations, err := p.Database.GetOrganizationsByUserID(r.Context(), apiKey.UserID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get organizations: %s", err.Error()),
})
return
}
organizationIDs := make([]string, 0, len(organizations))
for _, organization := range organizations {
organizationIDs = append(organizationIDs, organization.ID)
}
projects, err := p.Database.GetProjectsByOrganizationIDs(r.Context(), organizationIDs)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get projects: %s", err.Error()),
})
return
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, projects)
}
// allProjectsForOrganization lists all projects for a specific 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})
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get projects: %s", err.Error()),
})
return
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, projects)
}
// createProject makes 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) {
return
}
organization := httpmw.OrganizationParam(r)
_, err := p.Database.GetProjectByOrganizationAndName(r.Context(), database.GetProjectByOrganizationAndNameParams{
OrganizationID: organization.ID,
Name: createProject.Name,
})
if err == nil {
httpapi.Write(rw, http.StatusConflict, httpapi.Response{
Message: fmt.Sprintf("project %q already exists", createProject.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 project by name: %s", err.Error()),
})
return
}
project, err := p.Database.InsertProject(r.Context(), database.InsertProjectParams{
ID: uuid.New(),
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
OrganizationID: organization.ID,
Name: createProject.Name,
Provisioner: createProject.Provisioner,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("insert project: %s", err),
})
return
}
render.Status(r, http.StatusCreated)
render.JSON(rw, r, project)
}
// project returns a single project parsed from the URL path.
func (*projects) project(rw http.ResponseWriter, r *http.Request) {
project := httpmw.ProjectParam(r)
render.Status(r, http.StatusOK)
render.JSON(rw, r, project)
}
// projectVersions lists versions for a single project.
func (p *projects) projectVersions(rw http.ResponseWriter, r *http.Request) {
project := httpmw.ProjectParam(r)
history, err := p.Database.GetProjectHistoryByProjectID(r.Context(), project.ID)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get project history: %s", err),
})
return
}
versions := make([]ProjectVersion, 0)
for _, version := range history {
versions = append(versions, convertProjectHistory(version))
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, versions)
}
func (p *projects) createProjectVersion(rw http.ResponseWriter, r *http.Request) {
var createProjectVersion CreateProjectVersionRequest
if !httpapi.Read(rw, r, &createProjectVersion) {
return
}
switch createProjectVersion.StorageMethod {
case database.ProjectStorageMethodInlineArchive:
tarReader := tar.NewReader(bytes.NewReader(createProjectVersion.StorageSource))
_, err := tarReader.Next()
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: "the archive must be a tar",
})
return
}
default:
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: fmt.Sprintf("unsupported storage method %s", createProjectVersion.StorageMethod),
})
return
}
project := httpmw.ProjectParam(r)
history, err := p.Database.InsertProjectHistory(r.Context(), database.InsertProjectHistoryParams{
ID: uuid.New(),
ProjectID: project.ID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
Name: namesgenerator.GetRandomName(1),
StorageMethod: createProjectVersion.StorageMethod,
StorageSource: createProjectVersion.StorageSource,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("insert project history: %s", err),
})
return
}
// TODO: A job to process the new version should occur here.
render.Status(r, http.StatusCreated)
render.JSON(rw, r, convertProjectHistory(history))
}
func convertProjectHistory(history database.ProjectHistory) ProjectVersion {
return ProjectVersion{
ID: history.ID,
ProjectID: history.ProjectID,
CreatedAt: history.CreatedAt,
UpdatedAt: history.UpdatedAt,
Name: history.Name,
}
}

183
coderd/projects_test.go Normal file
View File

@ -0,0 +1,183 @@
package coderd_test
import (
"archive/tar"
"bytes"
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/database"
)
func TestProjects(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
_, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{
Name: "someproject",
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
})
t.Run("AlreadyExists", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
_, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{
Name: "someproject",
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
_, err = server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{
Name: "someproject",
Provisioner: database.ProvisionerTypeTerraform,
})
require.Error(t, err)
})
t.Run("ListEmpty", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_ = server.RandomInitialUser(t)
projects, err := server.Client.Projects(context.Background(), "")
require.NoError(t, err)
require.Len(t, projects, 0)
})
t.Run("List", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
_, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{
Name: "someproject",
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
// Ensure global query works.
projects, err := server.Client.Projects(context.Background(), "")
require.NoError(t, err)
require.Len(t, projects, 1)
// Ensure specified query works.
projects, err = server.Client.Projects(context.Background(), user.Organization)
require.NoError(t, err)
require.Len(t, projects, 1)
})
t.Run("ListEmpty", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
projects, err := server.Client.Projects(context.Background(), user.Organization)
require.NoError(t, err)
require.Len(t, projects, 0)
})
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: "someproject",
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
_, err = server.Client.Project(context.Background(), user.Organization, project.Name)
require.NoError(t, err)
})
t.Run("NoVersions", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{
Name: "someproject",
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
versions, err := server.Client.ProjectVersions(context.Background(), user.Organization, project.Name)
require.NoError(t, err)
require.Len(t, versions, 0)
})
t.Run("CreateVersion", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{
Name: "someproject",
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
var buffer bytes.Buffer
writer := tar.NewWriter(&buffer)
err = writer.WriteHeader(&tar.Header{
Name: "file",
Size: 1 << 10,
})
require.NoError(t, err)
_, err = writer.Write(make([]byte, 1<<10))
require.NoError(t, err)
_, err = server.Client.CreateProjectVersion(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{
Name: "moo",
StorageMethod: database.ProjectStorageMethodInlineArchive,
StorageSource: buffer.Bytes(),
})
require.NoError(t, err)
versions, err := server.Client.ProjectVersions(context.Background(), user.Organization, project.Name)
require.NoError(t, err)
require.Len(t, versions, 1)
})
t.Run("CreateVersionArchiveTooBig", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{
Name: "someproject",
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
var buffer bytes.Buffer
writer := tar.NewWriter(&buffer)
err = writer.WriteHeader(&tar.Header{
Name: "file",
Size: 1 << 21,
})
require.NoError(t, err)
_, err = writer.Write(make([]byte, 1<<21))
require.NoError(t, err)
_, err = server.Client.CreateProjectVersion(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{
Name: "moo",
StorageMethod: database.ProjectStorageMethodInlineArchive,
StorageSource: buffer.Bytes(),
})
require.Error(t, err)
})
t.Run("CreateVersionInvalidArchive", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{
Name: "someproject",
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
_, err = server.Client.CreateProjectVersion(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{
Name: "moo",
StorageMethod: database.ProjectStorageMethodInlineArchive,
StorageSource: []byte{},
})
require.Error(t, err)
})
}

View File

@ -9,7 +9,9 @@ import (
)
func TestUserPassword(t *testing.T) {
t.Parallel()
t.Run("Legacy", func(t *testing.T) {
t.Parallel()
// Ensures legacy v1 passwords function for v2.
// This has is manually generated using a print statement from v1 code.
equal, err := userpassword.Compare("$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg", "tomato")
@ -18,6 +20,7 @@ func TestUserPassword(t *testing.T) {
})
t.Run("Same", func(t *testing.T) {
t.Parallel()
hash, err := userpassword.Hash("password")
require.NoError(t, err)
equal, err := userpassword.Compare(hash, "password")
@ -26,6 +29,7 @@ func TestUserPassword(t *testing.T) {
})
t.Run("Different", func(t *testing.T) {
t.Parallel()
hash, err := userpassword.Hash("password")
require.NoError(t, err)
equal, err := userpassword.Compare(hash, "notpassword")
@ -34,12 +38,14 @@ func TestUserPassword(t *testing.T) {
})
t.Run("Invalid", func(t *testing.T) {
t.Parallel()
equal, err := userpassword.Compare("invalidhash", "password")
require.False(t, equal)
require.Error(t, err)
})
t.Run("InvalidParts", func(t *testing.T) {
t.Parallel()
equal, err := userpassword.Compare("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", "test")
require.False(t, equal)
require.Error(t, err)

View File

@ -35,6 +35,7 @@ func TestUsers(t *testing.T) {
})
t.Run("Login", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
_, err := server.Client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{