Skip to content

feat: add examples to api #5331

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Dec 9, 2022
Merged
Show file tree
Hide file tree
Changes from all 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 (
tarMimeType = "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 tarMimeType:
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: 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) {
Expand Down
70 changes: 70 additions & 0 deletions coderd/templateversions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -997,3 +1049,21 @@ func TestPreviousTemplateVersion(t *testing.T) {
require.Equal(t, previousVersion.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)
})
}
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
24 changes: 24 additions & 0 deletions codersdk/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Copy link
Member

@deansheather deansheather Dec 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are template examples org-scoped? Seems like you did it so it could be /templates/examples but I maybe you should use /api/v2/template-examples instead or something else since it's not org scoped.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are admin only so I felt it was better to keep it all under that same auth schema. Do you think it really impacts anything until we have multi-org?

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)
}
Loading