Skip to content

Commit 710a05a

Browse files
committed
feat: Add template export functionality to UI
1 parent f8f4dc6 commit 710a05a

File tree

3 files changed

+122
-0
lines changed

3 files changed

+122
-0
lines changed

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,7 @@ func New(options *Options) *API {
11141114
r.Get("/", api.template)
11151115
r.Delete("/", api.deleteTemplate)
11161116
r.Patch("/", api.patchTemplateMeta)
1117+
r.Get("/export", api.exportTemplate)
11171118
r.Route("/versions", func(r chi.Router) {
11181119
r.Post("/archive", api.postArchiveTemplateVersions)
11191120
r.Get("/", api.templateVersionsByTemplate)

coderd/templates.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package coderd
22

33
import (
4+
"archive/tar"
5+
"bytes"
46
"context"
57
"database/sql"
68
"errors"
79
"fmt"
10+
"io"
811
"net/http"
912
"sort"
1013
"time"
@@ -32,6 +35,7 @@ import (
3235
"github.com/coder/coder/v2/coderd/workspacestats"
3336
"github.com/coder/coder/v2/codersdk"
3437
"github.com/coder/coder/v2/examples"
38+
"compress/gzip"
3539
)
3640

3741
// Returns a single template.
@@ -1100,3 +1104,109 @@ func findTemplateAdmins(ctx context.Context, store database.Store) ([]database.G
11001104
}
11011105
return append(owners, templateAdmins...), nil
11021106
}
1107+
1108+
// @Summary Export template by ID
1109+
// @ID export-template-by-id
1110+
// @Security CoderSessionToken
1111+
// @Produce application/x-gzip
1112+
// @Tags Templates
1113+
// @Param template path string true "Template ID" format(uuid)
1114+
// @Success 200 {file} binary "Template archive"
1115+
// @Router /templates/{template}/export [get]
1116+
func (api *API) exportTemplate(rw http.ResponseWriter, r *http.Request) {
1117+
ctx := r.Context()
1118+
template := httpmw.TemplateParam(r)
1119+
1120+
// Get the latest version of the template
1121+
version, err := api.Database.GetTemplateVersionByTemplateIDAndName(ctx, database.GetTemplateVersionByTemplateIDAndNameParams{
1122+
TemplateID: template.ID,
1123+
Name: template.ActiveVersionID.String(),
1124+
})
1125+
if err != nil {
1126+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1127+
Message: "Failed to get template version.",
1128+
Detail: err.Error(),
1129+
})
1130+
return
1131+
}
1132+
1133+
// Create a buffer to store our archive
1134+
var buf bytes.Buffer
1135+
1136+
// Create gzip writer
1137+
gw := gzip.NewWriter(&buf)
1138+
tw := tar.NewWriter(gw)
1139+
1140+
// Add template files to archive
1141+
files := []struct {
1142+
Name string
1143+
Content string
1144+
}{
1145+
{
1146+
Name: "main.tf",
1147+
Content: version.Provisioner,
1148+
},
1149+
{
1150+
Name: "README.md",
1151+
Content: template.Description,
1152+
},
1153+
}
1154+
1155+
for _, file := range files {
1156+
hdr := &tar.Header{
1157+
Name: file.Name,
1158+
Mode: 0644,
1159+
Size: int64(len(file.Content)),
1160+
ModTime: time.Now(),
1161+
Format: tar.FormatPAX,
1162+
}
1163+
1164+
if err := tw.WriteHeader(hdr); err != nil {
1165+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1166+
Message: "Failed to write tar header.",
1167+
Detail: err.Error(),
1168+
})
1169+
return
1170+
}
1171+
1172+
if _, err := tw.Write([]byte(file.Content)); err != nil {
1173+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1174+
Message: "Failed to write file content.",
1175+
Detail: err.Error(),
1176+
})
1177+
return
1178+
}
1179+
}
1180+
1181+
// Close tar writer
1182+
if err := tw.Close(); err != nil {
1183+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1184+
Message: "Failed to close tar writer.",
1185+
Detail: err.Error(),
1186+
})
1187+
return
1188+
}
1189+
1190+
// Close gzip writer
1191+
if err := gw.Close(); err != nil {
1192+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1193+
Message: "Failed to close gzip writer.",
1194+
Detail: err.Error(),
1195+
})
1196+
return
1197+
}
1198+
1199+
// Set response headers
1200+
rw.Header().Set("Content-Type", "application/x-gzip")
1201+
rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.tar.gz", template.Name))
1202+
rw.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len()))
1203+
1204+
// Write the archive to the response
1205+
if _, err := io.Copy(rw, &buf); err != nil {
1206+
api.Logger.Error(ctx, "failed to write template archive to response",
1207+
slog.Error(err),
1208+
slog.F("template_id", template.ID),
1209+
)
1210+
return
1211+
}
1212+
}

site/src/pages/TemplatePage/TemplatePageHeader.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import EditIcon from "@mui/icons-material/EditOutlined";
2+
import DownloadIcon from "@mui/icons-material/Download";
23
import Button from "@mui/material/Button";
34
import { workspaces } from "api/queries/workspaces";
45
import type {
@@ -102,6 +103,16 @@ const TemplateMenu: FC<TemplateMenuProps> = ({
102103
<CopyIcon className="size-icon-sm" />
103104
Duplicate&hellip;
104105
</DropdownMenuItem>
106+
107+
<DropdownMenuItem
108+
onClick={() => {
109+
window.location.href = `/api/v2/templates/${templateId}/export`;
110+
}}
111+
>
112+
<DownloadIcon className="size-icon-sm" />
113+
Export template
114+
</DropdownMenuItem>
115+
105116
<DropdownMenuSeparator />
106117
<DropdownMenuItem
107118
className="text-content-destructive focus:text-content-destructive"

0 commit comments

Comments
 (0)