Skip to content

Commit ca0374b

Browse files
f0sseldeansheather
andauthored
feat: add examples to api (#5331)
Co-authored-by: Dean Sheather <dean@deansheather.com>
1 parent 6cc864c commit ca0374b

11 files changed

+252
-26
lines changed

cli/templateinit.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/spf13/cobra"
99

1010
"github.com/coder/coder/cli/cliui"
11+
"github.com/coder/coder/codersdk"
1112
"github.com/coder/coder/examples"
1213
"github.com/coder/coder/provisionersdk"
1314
)
@@ -22,7 +23,7 @@ func templateInit() *cobra.Command {
2223
return err
2324
}
2425
exampleNames := []string{}
25-
exampleByName := map[string]examples.Example{}
26+
exampleByName := map[string]codersdk.TemplateExample{}
2627
for _, example := range exampleList {
2728
name := fmt.Sprintf(
2829
"%s\n%s\n%s\n",

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ func New(options *Options) *API {
355355
r.Post("/", api.postTemplateByOrganization)
356356
r.Get("/", api.templatesByOrganization)
357357
r.Get("/{templatename}", api.templateByOrganizationAndName)
358+
r.Get("/examples", api.templateExamples)
358359
})
359360
r.Route("/members", func(r chi.Router) {
360361
r.Get("/roles", api.assignableOrgRoles)

coderd/files.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import (
1919
"github.com/coder/coder/codersdk"
2020
)
2121

22+
const (
23+
tarMimeType = "application/x-tar"
24+
)
25+
2226
func (api *API) postFile(rw http.ResponseWriter, r *http.Request) {
2327
ctx := r.Context()
2428
apiKey := httpmw.APIKey(r)
@@ -32,7 +36,7 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) {
3236
contentType := r.Header.Get("Content-Type")
3337

3438
switch contentType {
35-
case "application/x-tar":
39+
case tarMimeType:
3640
default:
3741
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
3842
Message: fmt.Sprintf("Unsupported content type header %q.", contentType),

coderd/templates.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/coder/coder/coderd/rbac"
2424
"github.com/coder/coder/coderd/telemetry"
2525
"github.com/coder/coder/codersdk"
26+
"github.com/coder/coder/examples"
2627
)
2728

2829
// 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) {
564565
httpapi.Write(ctx, rw, http.StatusOK, resp)
565566
}
566567

568+
func (api *API) templateExamples(rw http.ResponseWriter, r *http.Request) {
569+
var (
570+
ctx = r.Context()
571+
organization = httpmw.OrganizationParam(r)
572+
)
573+
574+
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceTemplate.InOrg(organization.ID)) {
575+
httpapi.ResourceNotFound(rw)
576+
return
577+
}
578+
579+
ex, err := examples.List()
580+
if err != nil {
581+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
582+
Message: "Internal error fetching examples.",
583+
Detail: err.Error(),
584+
})
585+
return
586+
}
587+
588+
httpapi.Write(ctx, rw, http.StatusOK, ex)
589+
}
590+
567591
type autoImportTemplateOpts struct {
568592
name string
569593
archive []byte

coderd/templateversions.go

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package coderd
22

33
import (
44
"context"
5+
"crypto/sha256"
56
"database/sql"
7+
"encoding/hex"
68
"encoding/json"
79
"errors"
810
"fmt"
@@ -23,6 +25,7 @@ import (
2325
"github.com/coder/coder/coderd/provisionerdserver"
2426
"github.com/coder/coder/coderd/rbac"
2527
"github.com/coder/coder/codersdk"
28+
"github.com/coder/coder/examples"
2629
)
2730

2831
func (api *API) templateVersion(rw http.ResponseWriter, r *http.Request) {
@@ -834,19 +837,79 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
834837
// Ensures the "owner" is properly applied.
835838
tags := provisionerdserver.MutateTags(apiKey.UserID, req.ProvisionerTags)
836839

837-
file, err := api.Database.GetFileByID(ctx, req.FileID)
838-
if errors.Is(err, sql.ErrNoRows) {
839-
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
840-
Message: "File not found.",
840+
if req.ExampleID != "" && req.FileID != uuid.Nil {
841+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
842+
Message: "You cannot specify both an example_id and a file_id.",
841843
})
842844
return
843845
}
844-
if err != nil {
845-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
846-
Message: "Internal error fetching file.",
847-
Detail: err.Error(),
846+
847+
var file database.File
848+
var err error
849+
// if example id is specified we need to copy the embedded tar into a new file in the database
850+
if req.ExampleID != "" {
851+
if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceFile.WithOwner(apiKey.UserID.String())) {
852+
httpapi.Forbidden(rw)
853+
return
854+
}
855+
// ensure we can read the file that either already exists or will be created
856+
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceFile.WithOwner(apiKey.UserID.String())) {
857+
httpapi.Forbidden(rw)
858+
return
859+
}
860+
861+
// lookup template tar from embedded examples
862+
tar, err := examples.Archive(req.ExampleID)
863+
if err != nil {
864+
if xerrors.Is(err, examples.ErrNotFound) {
865+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
866+
Message: "Example not found.",
867+
Detail: err.Error(),
868+
})
869+
return
870+
}
871+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
872+
Message: "Internal error fetching example.",
873+
Detail: err.Error(),
874+
})
875+
return
876+
}
877+
878+
// upload a copy of the template tar as a file in the database
879+
hashBytes := sha256.Sum256(tar)
880+
hash := hex.EncodeToString(hashBytes[:])
881+
file, err = api.Database.InsertFile(ctx, database.InsertFileParams{
882+
ID: uuid.New(),
883+
Hash: hash,
884+
CreatedBy: apiKey.UserID,
885+
CreatedAt: database.Now(),
886+
Mimetype: tarMimeType,
887+
Data: tar,
848888
})
849-
return
889+
if err != nil {
890+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
891+
Message: "Internal error creating file.",
892+
Detail: err.Error(),
893+
})
894+
return
895+
}
896+
}
897+
898+
if req.FileID != uuid.Nil {
899+
file, err = api.Database.GetFileByID(ctx, req.FileID)
900+
if errors.Is(err, sql.ErrNoRows) {
901+
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
902+
Message: "File not found.",
903+
})
904+
return
905+
}
906+
if err != nil {
907+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
908+
Message: "Internal error fetching file.",
909+
Detail: err.Error(),
910+
})
911+
return
912+
}
850913
}
851914

852915
if !api.Authorize(r, rbac.ActionRead, file) {

coderd/templateversions_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/coder/coder/coderd/database"
1616
"github.com/coder/coder/coderd/provisionerdserver"
1717
"github.com/coder/coder/codersdk"
18+
"github.com/coder/coder/examples"
1819
"github.com/coder/coder/provisioner/echo"
1920
"github.com/coder/coder/provisionersdk/proto"
2021
"github.com/coder/coder/testutil"
@@ -128,6 +129,57 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
128129
require.Len(t, auditor.AuditLogs, 1)
129130
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[0].Action)
130131
})
132+
t.Run("Example", func(t *testing.T) {
133+
t.Parallel()
134+
client := coderdtest.New(t, nil)
135+
user := coderdtest.CreateFirstUser(t, client)
136+
137+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
138+
defer cancel()
139+
140+
ls, err := examples.List()
141+
require.NoError(t, err)
142+
143+
// try a bad example ID
144+
_, err = client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{
145+
Name: "my-example",
146+
StorageMethod: codersdk.ProvisionerStorageMethodFile,
147+
ExampleID: "not a real ID",
148+
Provisioner: codersdk.ProvisionerTypeEcho,
149+
})
150+
require.Error(t, err)
151+
require.ErrorContains(t, err, "not found")
152+
153+
// try file and example IDs
154+
_, err = client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{
155+
Name: "my-example",
156+
StorageMethod: codersdk.ProvisionerStorageMethodFile,
157+
ExampleID: ls[0].ID,
158+
FileID: uuid.New(),
159+
Provisioner: codersdk.ProvisionerTypeEcho,
160+
})
161+
require.Error(t, err)
162+
require.ErrorContains(t, err, "example_id")
163+
require.ErrorContains(t, err, "file_id")
164+
165+
// try a good example ID
166+
tv, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{
167+
Name: "my-example",
168+
StorageMethod: codersdk.ProvisionerStorageMethodFile,
169+
ExampleID: ls[0].ID,
170+
Provisioner: codersdk.ProvisionerTypeEcho,
171+
})
172+
require.NoError(t, err)
173+
require.Equal(t, "my-example", tv.Name)
174+
175+
// ensure the template tar was uploaded correctly
176+
fl, ct, err := client.Download(ctx, tv.Job.FileID)
177+
require.NoError(t, err)
178+
require.Equal(t, "application/x-tar", ct)
179+
tar, err := examples.Archive(ls[0].ID)
180+
require.NoError(t, err)
181+
require.EqualValues(t, tar, fl)
182+
})
131183
}
132184

133185
func TestPatchCancelTemplateVersion(t *testing.T) {
@@ -1019,3 +1071,21 @@ func TestPreviousTemplateVersion(t *testing.T) {
10191071
require.Equal(t, templateBVersion1.ID, result.ID)
10201072
})
10211073
}
1074+
1075+
func TestTemplateExamples(t *testing.T) {
1076+
t.Parallel()
1077+
t.Run("OK", func(t *testing.T) {
1078+
t.Parallel()
1079+
client := coderdtest.New(t, nil)
1080+
user := coderdtest.CreateFirstUser(t, client)
1081+
1082+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
1083+
defer cancel()
1084+
1085+
ex, err := client.TemplateExamples(ctx, user.OrganizationID)
1086+
require.NoError(t, err)
1087+
ls, err := examples.List()
1088+
require.NoError(t, err)
1089+
require.EqualValues(t, ls, ex)
1090+
})
1091+
}

codersdk/organizations.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ type CreateTemplateVersionRequest struct {
3838
// TemplateID optionally associates a version with a template.
3939
TemplateID uuid.UUID `json:"template_id,omitempty"`
4040
StorageMethod ProvisionerStorageMethod `json:"storage_method" validate:"oneof=file,required"`
41-
FileID uuid.UUID `json:"file_id" validate:"required"`
41+
FileID uuid.UUID `json:"file_id,omitempty" validate:"required_without=ExampleID"`
42+
ExampleID string `json:"example_id,omitempty" validate:"required_without=FileID"`
4243
Provisioner ProvisionerType `json:"provisioner" validate:"oneof=terraform echo,required"`
4344
ProvisionerTags map[string]string `json:"tags"`
4445

codersdk/templates.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ type UpdateTemplateMeta struct {
8282
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"`
8383
}
8484

85+
type TemplateExample struct {
86+
ID string `json:"id"`
87+
URL string `json:"url"`
88+
Name string `json:"name"`
89+
Description string `json:"description"`
90+
Icon string `json:"icon"`
91+
Tags []string `json:"tags"`
92+
Markdown string `json:"markdown"`
93+
}
94+
8595
// Template returns a single template.
8696
func (c *Client) Template(ctx context.Context, template uuid.UUID) (Template, error) {
8797
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s", template), nil)
@@ -238,3 +248,17 @@ type AgentStatsReportResponse struct {
238248
// TxBytes is the number of transmitted bytes.
239249
TxBytes int64 `json:"tx_bytes"`
240250
}
251+
252+
// TemplateExamples lists example templates embedded in coder.
253+
func (c *Client) TemplateExamples(ctx context.Context, organizationID uuid.UUID) ([]TemplateExample, error) {
254+
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/templates/examples", organizationID), nil)
255+
if err != nil {
256+
return nil, err
257+
}
258+
defer res.Body.Close()
259+
if res.StatusCode != http.StatusOK {
260+
return nil, readBodyAsError(res)
261+
}
262+
var templateExamples []TemplateExample
263+
return templateExamples, json.NewDecoder(res.Body).Decode(&templateExamples)
264+
}

0 commit comments

Comments
 (0)