feat: add examples to api (#5331)

Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
Garrett Delfosse 2022-12-09 14:29:50 -05:00 committed by GitHub
parent 6cc864c048
commit ca0374b94f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 252 additions and 26 deletions

View File

@ -8,6 +8,7 @@ import (
"github.com/spf13/cobra"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/examples"
"github.com/coder/coder/provisionersdk"
)
@ -22,7 +23,7 @@ func templateInit() *cobra.Command {
return err
}
exampleNames := []string{}
exampleByName := map[string]examples.Example{}
exampleByName := map[string]codersdk.TemplateExample{}
for _, example := range exampleList {
name := fmt.Sprintf(
"%s\n%s\n%s\n",

View File

@ -355,6 +355,7 @@ func New(options *Options) *API {
r.Post("/", api.postTemplateByOrganization)
r.Get("/", api.templatesByOrganization)
r.Get("/{templatename}", api.templateByOrganizationAndName)
r.Get("/examples", api.templateExamples)
})
r.Route("/members", func(r chi.Router) {
r.Get("/roles", api.assignableOrgRoles)

View File

@ -19,6 +19,10 @@ import (
"github.com/coder/coder/codersdk"
)
const (
tarMimeType = "application/x-tar"
)
func (api *API) postFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
@ -32,7 +36,7 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
switch contentType {
case "application/x-tar":
case tarMimeType:
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Unsupported content type header %q.", contentType),

View File

@ -23,6 +23,7 @@ import (
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/examples"
)
// Auto-importable templates. These can be auto-imported after the first user
@ -564,6 +565,29 @@ func (api *API) templateDAUs(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
func (api *API) templateExamples(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
organization = httpmw.OrganizationParam(r)
)
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceTemplate.InOrg(organization.ID)) {
httpapi.ResourceNotFound(rw)
return
}
ex, err := examples.List()
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching examples.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, ex)
}
type autoImportTemplateOpts struct {
name string
archive []byte

View File

@ -2,7 +2,9 @@ package coderd
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@ -23,6 +25,7 @@ import (
"github.com/coder/coder/coderd/provisionerdserver"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/examples"
)
func (api *API) templateVersion(rw http.ResponseWriter, r *http.Request) {
@ -834,19 +837,79 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
// Ensures the "owner" is properly applied.
tags := provisionerdserver.MutateTags(apiKey.UserID, req.ProvisionerTags)
file, err := api.Database.GetFileByID(ctx, req.FileID)
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "File not found.",
if req.ExampleID != "" && req.FileID != uuid.Nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "You cannot specify both an example_id and a file_id.",
})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching file.",
Detail: err.Error(),
var file database.File
var err error
// if example id is specified we need to copy the embedded tar into a new file in the database
if req.ExampleID != "" {
if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceFile.WithOwner(apiKey.UserID.String())) {
httpapi.Forbidden(rw)
return
}
// ensure we can read the file that either already exists or will be created
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceFile.WithOwner(apiKey.UserID.String())) {
httpapi.Forbidden(rw)
return
}
// lookup template tar from embedded examples
tar, err := examples.Archive(req.ExampleID)
if err != nil {
if xerrors.Is(err, examples.ErrNotFound) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Example not found.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching example.",
Detail: err.Error(),
})
return
}
// upload a copy of the template tar as a file in the database
hashBytes := sha256.Sum256(tar)
hash := hex.EncodeToString(hashBytes[:])
file, err = api.Database.InsertFile(ctx, database.InsertFileParams{
ID: uuid.New(),
Hash: hash,
CreatedBy: apiKey.UserID,
CreatedAt: database.Now(),
Mimetype: tarMimeType,
Data: tar,
})
return
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error creating file.",
Detail: err.Error(),
})
return
}
}
if req.FileID != uuid.Nil {
file, err = api.Database.GetFileByID(ctx, req.FileID)
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "File not found.",
})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching file.",
Detail: err.Error(),
})
return
}
}
if !api.Authorize(r, rbac.ActionRead, file) {

View File

@ -15,6 +15,7 @@ import (
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/provisionerdserver"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/examples"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/testutil"
@ -128,6 +129,57 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
require.Len(t, auditor.AuditLogs, 1)
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[0].Action)
})
t.Run("Example", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
ls, err := examples.List()
require.NoError(t, err)
// try a bad example ID
_, err = client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{
Name: "my-example",
StorageMethod: codersdk.ProvisionerStorageMethodFile,
ExampleID: "not a real ID",
Provisioner: codersdk.ProvisionerTypeEcho,
})
require.Error(t, err)
require.ErrorContains(t, err, "not found")
// try file and example IDs
_, err = client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{
Name: "my-example",
StorageMethod: codersdk.ProvisionerStorageMethodFile,
ExampleID: ls[0].ID,
FileID: uuid.New(),
Provisioner: codersdk.ProvisionerTypeEcho,
})
require.Error(t, err)
require.ErrorContains(t, err, "example_id")
require.ErrorContains(t, err, "file_id")
// try a good example ID
tv, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{
Name: "my-example",
StorageMethod: codersdk.ProvisionerStorageMethodFile,
ExampleID: ls[0].ID,
Provisioner: codersdk.ProvisionerTypeEcho,
})
require.NoError(t, err)
require.Equal(t, "my-example", tv.Name)
// ensure the template tar was uploaded correctly
fl, ct, err := client.Download(ctx, tv.Job.FileID)
require.NoError(t, err)
require.Equal(t, "application/x-tar", ct)
tar, err := examples.Archive(ls[0].ID)
require.NoError(t, err)
require.EqualValues(t, tar, fl)
})
}
func TestPatchCancelTemplateVersion(t *testing.T) {
@ -1019,3 +1071,21 @@ func TestPreviousTemplateVersion(t *testing.T) {
require.Equal(t, templateBVersion1.ID, result.ID)
})
}
func TestTemplateExamples(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
ex, err := client.TemplateExamples(ctx, user.OrganizationID)
require.NoError(t, err)
ls, err := examples.List()
require.NoError(t, err)
require.EqualValues(t, ls, ex)
})
}

View File

@ -38,7 +38,8 @@ type CreateTemplateVersionRequest struct {
// TemplateID optionally associates a version with a template.
TemplateID uuid.UUID `json:"template_id,omitempty"`
StorageMethod ProvisionerStorageMethod `json:"storage_method" validate:"oneof=file,required"`
FileID uuid.UUID `json:"file_id" validate:"required"`
FileID uuid.UUID `json:"file_id,omitempty" validate:"required_without=ExampleID"`
ExampleID string `json:"example_id,omitempty" validate:"required_without=FileID"`
Provisioner ProvisionerType `json:"provisioner" validate:"oneof=terraform echo,required"`
ProvisionerTags map[string]string `json:"tags"`

View File

@ -82,6 +82,16 @@ type UpdateTemplateMeta struct {
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"`
}
type TemplateExample struct {
ID string `json:"id"`
URL string `json:"url"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
Tags []string `json:"tags"`
Markdown string `json:"markdown"`
}
// Template returns a single template.
func (c *Client) Template(ctx context.Context, template uuid.UUID) (Template, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s", template), nil)
@ -238,3 +248,17 @@ type AgentStatsReportResponse struct {
// TxBytes is the number of transmitted bytes.
TxBytes int64 `json:"tx_bytes"`
}
// TemplateExamples lists example templates embedded in coder.
func (c *Client) TemplateExamples(ctx context.Context, organizationID uuid.UUID) ([]TemplateExample, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/templates/examples", organizationID), nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var templateExamples []TemplateExample
return templateExamples, json.NewDecoder(res.Body).Decode(&templateExamples)
}

View File

@ -12,6 +12,8 @@ import (
"github.com/gohugoio/hugo/parser/pageparser"
"golang.org/x/sync/singleflight"
"golang.org/x/xerrors"
"github.com/coder/coder/codersdk"
)
var (
@ -19,23 +21,16 @@ var (
files embed.FS
exampleBasePath = "https://github.com/coder/coder/tree/main/examples/templates/"
examples = make([]Example, 0)
examples = make([]codersdk.TemplateExample, 0)
parseExamples sync.Once
archives = singleflight.Group{}
ErrNotFound = xerrors.New("example not found")
)
type Example struct {
ID string `json:"id"`
URL string `json:"url"`
Name string `json:"name"`
Description string `json:"description"`
Markdown string `json:"markdown"`
}
const rootDir = "templates"
// List returns all embedded examples.
func List() ([]Example, error) {
func List() ([]codersdk.TemplateExample, error) {
var returnError error
parseExamples.Do(func() {
files, err := fs.Sub(files, rootDir)
@ -92,11 +87,41 @@ func List() ([]Example, error) {
return
}
examples = append(examples, Example{
tags := []string{}
tagsRaw, exists := frontMatter.FrontMatter["tags"]
if exists {
tagsI, valid := tagsRaw.([]interface{})
if !valid {
returnError = xerrors.Errorf("example %q tags isn't a slice: type %T", exampleID, tagsRaw)
return
}
for _, tagI := range tagsI {
tag, valid := tagI.(string)
if !valid {
returnError = xerrors.Errorf("example %q tag isn't a string: type %T", exampleID, tagI)
return
}
tags = append(tags, tag)
}
}
var icon string
iconRaw, exists := frontMatter.FrontMatter["icon"]
if exists {
icon, valid = iconRaw.(string)
if !valid {
returnError = xerrors.Errorf("example %q icon isn't a string", exampleID)
return
}
}
examples = append(examples, codersdk.TemplateExample{
ID: exampleID,
URL: exampleURL,
Name: name,
Description: description,
Icon: icon,
Tags: tags,
Markdown: string(frontMatter.Content),
})
}
@ -112,7 +137,7 @@ func Archive(exampleID string) ([]byte, error) {
return nil, xerrors.Errorf("list: %w", err)
}
var selected Example
var selected codersdk.TemplateExample
for _, example := range examples {
if example.ID != exampleID {
continue
@ -122,7 +147,7 @@ func Archive(exampleID string) ([]byte, error) {
}
if selected.ID == "" {
return nil, xerrors.Errorf("example with id %q not found", exampleID)
return nil, xerrors.Errorf("example with id %q not found: %w", exampleID, ErrNotFound)
}
exampleFiles, err := fs.Sub(files, path.Join(rootDir, exampleID))

View File

@ -27,6 +27,7 @@ func TestTemplate(t *testing.T) {
assert.NotEmpty(t, eg.Name, "example name should not be empty")
assert.NotEmpty(t, eg.Description, "example description should not be empty")
assert.NotEmpty(t, eg.Markdown, "example markdown should not be empty")
assert.NotNil(t, eg.Tags, "example tags should not be nil, should be empty array if no tags")
_, err := examples.Archive(eg.ID)
assert.NoError(t, err, "error archiving example")
})

View File

@ -189,7 +189,8 @@ export interface CreateTemplateVersionRequest {
readonly name?: string
readonly template_id?: string
readonly storage_method: ProvisionerStorageMethod
readonly file_id: string
readonly file_id?: string
readonly example_id?: string
readonly provisioner: ProvisionerType
readonly tags: Record<string, string>
readonly parameter_values?: CreateParameterRequest[]
@ -668,6 +669,17 @@ export interface TemplateDAUsResponse {
readonly entries: DAUEntry[]
}
// From codersdk/templates.go
export interface TemplateExample {
readonly id: string
readonly url: string
readonly name: string
readonly description: string
readonly icon: string
readonly tags: string[]
readonly markdown: string
}
// From codersdk/templates.go
export interface TemplateGroup extends Group {
readonly role: TemplateRole