From 710a05a08280db5aba868d4b45905f4733962210 Mon Sep 17 00:00:00 2001 From: keerthi Date: Fri, 16 May 2025 23:57:07 +0530 Subject: [PATCH] feat: Add template export functionality to UI --- coderd/coderd.go | 1 + coderd/templates.go | 110 ++++++++++++++++++ .../pages/TemplatePage/TemplatePageHeader.tsx | 11 ++ 3 files changed, 122 insertions(+) diff --git a/coderd/coderd.go b/coderd/coderd.go index c3f45b15e4a30..911596763e6e8 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1114,6 +1114,7 @@ func New(options *Options) *API { r.Get("/", api.template) r.Delete("/", api.deleteTemplate) r.Patch("/", api.patchTemplateMeta) + r.Get("/export", api.exportTemplate) r.Route("/versions", func(r chi.Router) { r.Post("/archive", api.postArchiveTemplateVersions) r.Get("/", api.templateVersionsByTemplate) diff --git a/coderd/templates.go b/coderd/templates.go index 2a3e0326b1970..69f718dfab070 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -1,10 +1,13 @@ package coderd import ( + "archive/tar" + "bytes" "context" "database/sql" "errors" "fmt" + "io" "net/http" "sort" "time" @@ -32,6 +35,7 @@ import ( "github.com/coder/coder/v2/coderd/workspacestats" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/examples" + "compress/gzip" ) // Returns a single template. @@ -1100,3 +1104,109 @@ func findTemplateAdmins(ctx context.Context, store database.Store) ([]database.G } return append(owners, templateAdmins...), nil } + +// @Summary Export template by ID +// @ID export-template-by-id +// @Security CoderSessionToken +// @Produce application/x-gzip +// @Tags Templates +// @Param template path string true "Template ID" format(uuid) +// @Success 200 {file} binary "Template archive" +// @Router /templates/{template}/export [get] +func (api *API) exportTemplate(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + template := httpmw.TemplateParam(r) + + // Get the latest version of the template + version, err := api.Database.GetTemplateVersionByTemplateIDAndName(ctx, database.GetTemplateVersionByTemplateIDAndNameParams{ + TemplateID: template.ID, + Name: template.ActiveVersionID.String(), + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get template version.", + Detail: err.Error(), + }) + return + } + + // Create a buffer to store our archive + var buf bytes.Buffer + + // Create gzip writer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + // Add template files to archive + files := []struct { + Name string + Content string + }{ + { + Name: "main.tf", + Content: version.Provisioner, + }, + { + Name: "README.md", + Content: template.Description, + }, + } + + for _, file := range files { + hdr := &tar.Header{ + Name: file.Name, + Mode: 0644, + Size: int64(len(file.Content)), + ModTime: time.Now(), + Format: tar.FormatPAX, + } + + if err := tw.WriteHeader(hdr); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to write tar header.", + Detail: err.Error(), + }) + return + } + + if _, err := tw.Write([]byte(file.Content)); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to write file content.", + Detail: err.Error(), + }) + return + } + } + + // Close tar writer + if err := tw.Close(); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to close tar writer.", + Detail: err.Error(), + }) + return + } + + // Close gzip writer + if err := gw.Close(); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to close gzip writer.", + Detail: err.Error(), + }) + return + } + + // Set response headers + rw.Header().Set("Content-Type", "application/x-gzip") + rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.tar.gz", template.Name)) + rw.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len())) + + // Write the archive to the response + if _, err := io.Copy(rw, &buf); err != nil { + api.Logger.Error(ctx, "failed to write template archive to response", + slog.Error(err), + slog.F("template_id", template.ID), + ) + return + } +} diff --git a/site/src/pages/TemplatePage/TemplatePageHeader.tsx b/site/src/pages/TemplatePage/TemplatePageHeader.tsx index 94883e7b6c134..0a563fd415114 100644 --- a/site/src/pages/TemplatePage/TemplatePageHeader.tsx +++ b/site/src/pages/TemplatePage/TemplatePageHeader.tsx @@ -1,4 +1,5 @@ import EditIcon from "@mui/icons-material/EditOutlined"; +import DownloadIcon from "@mui/icons-material/Download"; import Button from "@mui/material/Button"; import { workspaces } from "api/queries/workspaces"; import type { @@ -102,6 +103,16 @@ const TemplateMenu: FC = ({ Duplicate… + + { + window.location.href = `/api/v2/templates/${templateId}/export`; + }} + > + + Export template + +