mirror of https://github.com/coder/coder.git
feat: add examples to api (#5331)
Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
parent
6cc864c048
commit
ca0374b94f
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue