Skip to content

feat: Add template pull cmd #2329

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 6 commits into from
Jun 15, 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
123 changes: 123 additions & 0 deletions cli/templatepull.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package cli

import (
"fmt"
"io/fs"
"os"
"sort"

"github.com/spf13/cobra"
"golang.org/x/xerrors"

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

func templatePull() *cobra.Command {
cmd := &cobra.Command{
Use: "pull <name> [destination]",
Short: "Download the latest version of a template to a path.",
Args: cobra.MaximumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
var (
ctx = cmd.Context()
templateName = args[0]
dest string
)

if len(args) > 1 {
dest = args[1]
}

client, err := createClient(cmd)
if err != nil {
return xerrors.Errorf("create client: %w", err)
}

// TODO(JonA): Do we need to add a flag for organization?
organization, err := currentOrganization(cmd, client)
if err != nil {
return xerrors.Errorf("current organization: %w", err)
}

template, err := client.TemplateByName(ctx, organization.ID, templateName)
if err != nil {
return xerrors.Errorf("template by name: %w", err)
}

// Pull the versions for the template. We'll find the latest
// one and download the source.
versions, err := client.TemplateVersionsByTemplate(ctx, codersdk.TemplateVersionsByTemplateRequest{
TemplateID: template.ID,
})
if err != nil {
return xerrors.Errorf("template versions by template: %w", err)
}

if len(versions) == 0 {
return xerrors.Errorf("no template versions for template %q", templateName)
}

// Sort the slice from newest to oldest template.
sort.SliceStable(versions, func(i, j int) bool {
return versions[i].CreatedAt.After(versions[j].CreatedAt)
})

latest := versions[0]

// Download the tar archive.
raw, ctype, err := client.Download(ctx, latest.Job.StorageSource)
if err != nil {
return xerrors.Errorf("download template: %w", err)
}

if ctype != codersdk.ContentTypeTar {
return xerrors.Errorf("unexpected Content-Type %q, expecting %q", ctype, codersdk.ContentTypeTar)
}

// If the destination is empty then we write to stdout
// and bail early.
if dest == "" {
_, err = cmd.OutOrStdout().Write(raw)
if err != nil {
return xerrors.Errorf("write stdout: %w", err)
}
return nil
}

// Stat the destination to ensure nothing exists already.
fi, err := os.Stat(dest)
if err != nil && !xerrors.Is(err, fs.ErrNotExist) {
return xerrors.Errorf("stat destination: %w", err)
}

if fi != nil && fi.IsDir() {
// If the destination is a directory we just bail.
return xerrors.Errorf("%q already exists.", dest)
}

// If a file exists at the destination prompt the user
// to ensure we don't overwrite something valuable.
if fi != nil {
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
Text: fmt.Sprintf("%q already exists, do you want to overwrite it?", dest),
IsConfirm: true,
})
if err != nil {
return xerrors.Errorf("parse prompt: %w", err)
}
}

err = os.WriteFile(dest, raw, 0600)
if err != nil {
return xerrors.Errorf("write to path: %w", err)
}

return nil
},
}

cliui.AllowSkipPrompt(cmd)

return cmd
}
143 changes: 143 additions & 0 deletions cli/templatepull_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package cli_test

import (
"bytes"
"os"
"path/filepath"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/require"

"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/pty/ptytest"
)

func TestTemplatePull(t *testing.T) {
t.Parallel()

// Stdout tests that 'templates pull' pulls down the latest template
// and writes it to stdout.
t.Run("Stdout", func(t *testing.T) {
t.Parallel()

client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
user := coderdtest.CreateFirstUser(t, client)

// Create an initial template bundle.
source1 := genTemplateVersionSource()
// Create an updated template bundle. This will be used to ensure
// that templates are correctly returned in order from latest to oldest.
source2 := genTemplateVersionSource()

expected, err := echo.Tar(source2)
require.NoError(t, err)

version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID)

template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID)

// Update the template version so that we can assert that templates
// are being sorted correctly.
_ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID)

cmd, root := clitest.New(t, "templates", "pull", template.Name)
clitest.SetupConfig(t, client, root)

var buf bytes.Buffer
cmd.SetOut(&buf)

err = cmd.Execute()
require.NoError(t, err)

require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ")
})

// ToFile tests that 'templates pull' pulls down the latest template
// and writes it to the correct directory.
t.Run("ToFile", func(t *testing.T) {
t.Parallel()

client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
user := coderdtest.CreateFirstUser(t, client)

// Create an initial template bundle.
source1 := genTemplateVersionSource()
// Create an updated template bundle. This will be used to ensure
// that templates are correctly returned in order from latest to oldest.
source2 := genTemplateVersionSource()

expected, err := echo.Tar(source2)
require.NoError(t, err)

version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID)

template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID)

// Update the template version so that we can assert that templates
// are being sorted correctly.
_ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID)

dir := t.TempDir()

dest := filepath.Join(dir, "actual.tar")

// Create the file so that we can test that the command
// warns the user before overwriting a preexisting file.
fi, err := os.OpenFile(dest, os.O_CREATE|os.O_RDONLY, 0600)
require.NoError(t, err)
_ = fi.Close()

cmd, root := clitest.New(t, "templates", "pull", template.Name, dest)
clitest.SetupConfig(t, client, root)

pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())

errChan := make(chan error)
go func() {
defer close(errChan)
errChan <- cmd.Execute()
}()

// We expect to be prompted that a file already exists.
pty.ExpectMatch("already exists")
pty.WriteLine("yes")

require.NoError(t, <-errChan)

actual, err := os.ReadFile(dest)
require.NoError(t, err)

require.True(t, bytes.Equal(actual, expected), "tar files differ")
})
}

// genTemplateVersionSource returns a unique bundle that can be used to create
// a template version source.
func genTemplateVersionSource() *echo.Responses {
return &echo.Responses{
Parse: []*proto.Parse_Response{
{
Type: &proto.Parse_Response_Log{
Log: &proto.Log{
Output: uuid.NewString(),
},
},
},

{
Type: &proto.Parse_Response_Complete{
Complete: &proto.Parse_Complete{},
},
},
},
Provision: echo.ProvisionComplete,
}
}
1 change: 1 addition & 0 deletions cli/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func templates() *cobra.Command {
templateUpdate(),
templateVersions(),
templateDelete(),
templatePull(),
)

return cmd
Expand Down
7 changes: 4 additions & 3 deletions coderd/provisionerjobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,10 @@ func convertProvisionerJobLog(provisionerJobLog database.ProvisionerJobLog) code

func convertProvisionerJob(provisionerJob database.ProvisionerJob) codersdk.ProvisionerJob {
job := codersdk.ProvisionerJob{
ID: provisionerJob.ID,
CreatedAt: provisionerJob.CreatedAt,
Error: provisionerJob.Error.String,
ID: provisionerJob.ID,
CreatedAt: provisionerJob.CreatedAt,
Error: provisionerJob.Error.String,
StorageSource: provisionerJob.StorageSource,
}
// Applying values optional to the struct.
if provisionerJob.StartedAt.Valid {
Expand Down
15 changes: 8 additions & 7 deletions codersdk/provisionerdaemons.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,14 @@ const (
)

type ProvisionerJob struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Error string `json:"error,omitempty"`
Status ProvisionerJobStatus `json:"status"`
WorkerID *uuid.UUID `json:"worker_id,omitempty"`
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Error string `json:"error,omitempty"`
Status ProvisionerJobStatus `json:"status"`
WorkerID *uuid.UUID `json:"worker_id,omitempty"`
StorageSource string `json:"storage_source"`
}

type ProvisionerJobLog struct {
Expand Down
3 changes: 2 additions & 1 deletion site/src/api/typesGenerated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,10 @@ export interface ProvisionerJob {
readonly error?: string
readonly status: ProvisionerJobStatus
readonly worker_id?: string
readonly storage_source: string
}

// From codersdk/provisionerdaemons.go:72:6
// From codersdk/provisionerdaemons.go:73:6
export interface ProvisionerJobLog {
readonly id: string
readonly created_at: string
Expand Down
2 changes: 2 additions & 0 deletions site/src/testHelpers/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ export const MockProvisionerJob: TypesGen.ProvisionerJob = {
created_at: "",
id: "test-provisioner-job",
status: "succeeded",
storage_source: "asdf",
}

export const MockFailedProvisionerJob: TypesGen.ProvisionerJob = {
...MockProvisionerJob,
status: "failed",
Expand Down