Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cli/templateinit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion coderd/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import (
"github.com/coder/coder/codersdk"
)

const (
uploadFileContentTypeHeader = "application/x-tar"
)

func (api *API) postFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
Expand All @@ -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 uploadFileContentTypeHeader:
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Unsupported content type header %q.", contentType),
Expand Down
24 changes: 24 additions & 0 deletions coderd/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
81 changes: 72 additions & 9 deletions coderd/templateversions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package coderd

import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
Expand All @@ -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) {
Expand Down Expand Up @@ -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: uploadFileContentTypeHeader,
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) {
Expand Down
3 changes: 2 additions & 1 deletion codersdk/organizations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
8 changes: 8 additions & 0 deletions codersdk/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ 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"`
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)
Expand Down
21 changes: 8 additions & 13 deletions examples/examples.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,25 @@ import (
"github.com/gohugoio/hugo/parser/pageparser"
"golang.org/x/sync/singleflight"
"golang.org/x/xerrors"

"github.com/coder/coder/codersdk"
)

var (
//go:embed templates
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)
Expand Down Expand Up @@ -92,7 +87,7 @@ func List() ([]Example, error) {
return
}

examples = append(examples, Example{
examples = append(examples, codersdk.TemplateExample{
ID: exampleID,
URL: exampleURL,
Name: name,
Expand All @@ -112,7 +107,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
Expand All @@ -122,7 +117,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))
Expand Down
12 changes: 11 additions & 1 deletion site/src/api/typesGenerated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -668,6 +669,15 @@ 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 markdown: string
}

// From codersdk/templates.go
export interface TemplateGroup extends Group {
readonly role: TemplateRole
Expand Down