feat: Add "coder projects create" command (#246)

* Refactor parameter parsing to return nil values if none computed

* Refactor parameter to allow for hiding redisplay

* Refactor parameters to enable schema matching

* Refactor provisionerd to dynamically update parameter schemas

* Refactor job update for provisionerd

* Handle multiple states correctly when provisioning a project

* Add project import job resource table

* Basic creation flow works!

* Create project fully works!!!

* Only show job status if completed

* Add create workspace support

* Replace Netflix/go-expect with ActiveState

* Fix linting errors

* Use forked chzyer/readline

* Add create workspace CLI

* Add CLI test

* Move jobs to their own APIs

* Remove go-expect

* Fix requested changes

* Skip workspacecreate test on windows
This commit is contained in:
Kyle Carberry
2022-02-12 13:34:04 -06:00
committed by GitHub
parent df13fef161
commit 154b9bce57
65 changed files with 3215 additions and 2241 deletions
+79 -3
View File
@@ -1,16 +1,34 @@
package clitest
import (
"archive/tar"
"bufio"
"bytes"
"errors"
"io"
"os"
"path/filepath"
"regexp"
"testing"
"github.com/Netflix/go-expect"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli"
"github.com/coder/coder/cli/config"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisioner/echo"
)
var (
// Used to ensure terminal output doesn't have anything crazy!
// See: https://stackoverflow.com/a/29497680
stripAnsi = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))")
)
// New creates a CLI instance with a configuration pointed to a
// temporary testing directory.
func New(t *testing.T, args ...string) (*cobra.Command, config.Root) {
cmd := cli.Root()
dir := t.TempDir()
@@ -19,7 +37,27 @@ func New(t *testing.T, args ...string) (*cobra.Command, config.Root) {
return cmd, root
}
func StdoutLogs(t *testing.T) io.Writer {
// SetupConfig applies the URL and SessionToken of the client to the config.
func SetupConfig(t *testing.T, client *codersdk.Client, root config.Root) {
err := root.Session().Write(client.SessionToken)
require.NoError(t, err)
err = root.URL().Write(client.URL.String())
require.NoError(t, err)
}
// CreateProjectVersionSource writes the echo provisioner responses into a
// new temporary testing directory.
func CreateProjectVersionSource(t *testing.T, responses *echo.Responses) string {
directory := t.TempDir()
data, err := echo.Tar(responses)
require.NoError(t, err)
extractTar(t, data, directory)
return directory
}
// NewConsole creates a new TTY bound to the command provided.
// All ANSI escape codes are stripped to provide clean output.
func NewConsole(t *testing.T, cmd *cobra.Command) *expect.Console {
reader, writer := io.Pipe()
scanner := bufio.NewScanner(reader)
t.Cleanup(func() {
@@ -31,8 +69,46 @@ func StdoutLogs(t *testing.T) io.Writer {
if scanner.Err() != nil {
return
}
t.Log(scanner.Text())
t.Log(stripAnsi.ReplaceAllString(scanner.Text(), ""))
}
}()
return writer
console, err := expect.NewConsole(expect.WithStdout(writer))
require.NoError(t, err)
cmd.SetIn(console.Tty())
cmd.SetOut(console.Tty())
return console
}
func extractTar(t *testing.T, data []byte, directory string) {
reader := tar.NewReader(bytes.NewBuffer(data))
for {
header, err := reader.Next()
if errors.Is(err, io.EOF) {
break
}
require.NoError(t, err)
// #nosec
path := filepath.Join(directory, header.Name)
mode := header.FileInfo().Mode()
if mode == 0 {
mode = 0600
}
switch header.Typeflag {
case tar.TypeDir:
err = os.MkdirAll(path, mode)
require.NoError(t, err)
case tar.TypeReg:
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, mode)
require.NoError(t, err)
// Max file size of 10MB.
_, err = io.CopyN(file, reader, (1<<20)*10)
if errors.Is(err, io.EOF) {
err = nil
}
require.NoError(t, err)
err = file.Close()
require.NoError(t, err)
}
}
}
+31
View File
@@ -0,0 +1,31 @@
//go:build !windows
package clitest_test
import (
"testing"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func TestCli(t *testing.T) {
t.Parallel()
clitest.CreateProjectVersionSource(t, nil)
client := coderdtest.New(t)
cmd, config := clitest.New(t)
clitest.SetupConfig(t, client, config)
console := clitest.NewConsole(t, cmd)
go func() {
err := cmd.Execute()
require.NoError(t, err)
}()
_, err := console.ExpectString("coder")
require.NoError(t, err)
}
+5 -5
View File
@@ -49,7 +49,7 @@ func login() *cobra.Command {
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", color.HiBlackString(">"))
_, err := runPrompt(cmd, &promptui.Prompt{
_, err := prompt(cmd, &promptui.Prompt{
Label: "Would you like to create the first user?",
IsConfirm: true,
Default: "y",
@@ -61,7 +61,7 @@ func login() *cobra.Command {
if err != nil {
return xerrors.Errorf("get current user: %w", err)
}
username, err := runPrompt(cmd, &promptui.Prompt{
username, err := prompt(cmd, &promptui.Prompt{
Label: "What username would you like?",
Default: currentUser.Username,
})
@@ -69,7 +69,7 @@ func login() *cobra.Command {
return xerrors.Errorf("pick username prompt: %w", err)
}
organization, err := runPrompt(cmd, &promptui.Prompt{
organization, err := prompt(cmd, &promptui.Prompt{
Label: "What is the name of your organization?",
Default: "acme-corp",
})
@@ -77,7 +77,7 @@ func login() *cobra.Command {
return xerrors.Errorf("pick organization prompt: %w", err)
}
email, err := runPrompt(cmd, &promptui.Prompt{
email, err := prompt(cmd, &promptui.Prompt{
Label: "What's your email?",
Validate: func(s string) error {
err := validator.New().Var(s, "email")
@@ -91,7 +91,7 @@ func login() *cobra.Command {
return xerrors.Errorf("specify email prompt: %w", err)
}
password, err := runPrompt(cmd, &promptui.Prompt{
password, err := prompt(cmd, &promptui.Prompt{
Label: "Enter a password:",
Mask: '*',
})
+3 -8
View File
@@ -8,8 +8,6 @@ import (
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/stretchr/testify/require"
"github.com/Netflix/go-expect"
)
func TestLogin(t *testing.T) {
@@ -24,12 +22,9 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserTTY", func(t *testing.T) {
t.Parallel()
console, err := expect.NewConsole(expect.WithStdout(clitest.StdoutLogs(t)))
require.NoError(t, err)
client := coderdtest.New(t)
root, _ := clitest.New(t, "login", client.URL.String())
root.SetIn(console.Tty())
root.SetOut(console.Tty())
console := clitest.NewConsole(t, root)
go func() {
err := root.Execute()
require.NoError(t, err)
@@ -45,12 +40,12 @@ func TestLogin(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
_, err = console.ExpectString(match)
_, err := console.ExpectString(match)
require.NoError(t, err)
_, err = console.SendLine(value)
require.NoError(t, err)
}
_, err = console.ExpectString("Welcome to Coder")
_, err := console.ExpectString("Welcome to Coder")
require.NoError(t, err)
})
}
+154 -40
View File
@@ -3,7 +3,7 @@ package cli
import (
"archive/tar"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
@@ -12,17 +12,24 @@ import (
"github.com/briandowns/spinner"
"github.com/fatih/color"
"github.com/google/uuid"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/parameter"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/provisionerd"
)
func projectCreate() *cobra.Command {
return &cobra.Command{
var (
directory string
provisioner string
)
cmd := &cobra.Command{
Use: "create",
Short: "Create a project from the current directory",
RunE: func(cmd *cobra.Command, args []string) error {
@@ -34,27 +41,24 @@ func projectCreate() *cobra.Command {
if err != nil {
return err
}
workingDir, err := os.Getwd()
if err != nil {
return err
}
_, err = runPrompt(cmd, &promptui.Prompt{
_, err = prompt(cmd, &promptui.Prompt{
Default: "y",
IsConfirm: true,
Label: fmt.Sprintf("Set up %s in your organization?", color.New(color.FgHiCyan).Sprintf("%q", workingDir)),
Label: fmt.Sprintf("Set up %s in your organization?", color.New(color.FgHiCyan).Sprintf("%q", directory)),
})
if err != nil {
if errors.Is(err, promptui.ErrAbort) {
return nil
}
return err
}
name, err := runPrompt(cmd, &promptui.Prompt{
Default: filepath.Base(workingDir),
name, err := prompt(cmd, &promptui.Prompt{
Default: filepath.Base(directory),
Label: "What's your project's name?",
Validate: func(s string) error {
_, err = client.Project(cmd.Context(), organization.Name, s)
if err == nil {
project, _ := client.Project(cmd.Context(), organization.Name, s)
if project.ID.String() != uuid.Nil.String() {
return xerrors.New("A project already exists with that name!")
}
return nil
@@ -64,49 +68,159 @@ func projectCreate() *cobra.Command {
return err
}
spin := spinner.New(spinner.CharSets[0], 50*time.Millisecond)
spin.Suffix = " Uploading current directory..."
spin.Start()
defer spin.Stop()
bytes, err := tarDirectory(workingDir)
job, err := validateProjectVersionSource(cmd, client, organization, database.ProvisionerType(provisioner), directory)
if err != nil {
return err
}
resp, err := client.UploadFile(cmd.Context(), codersdk.ContentTypeTar, bytes)
if err != nil {
return err
}
job, err := client.CreateProjectVersionImportProvisionerJob(cmd.Context(), organization.Name, coderd.CreateProjectImportJobRequest{
StorageMethod: database.ProvisionerStorageMethodFile,
StorageSource: resp.Hash,
Provisioner: database.ProvisionerTypeTerraform,
// SkipResources on first import to detect variables defined by the project.
SkipResources: true,
project, err := client.CreateProject(cmd.Context(), organization.Name, coderd.CreateProjectRequest{
Name: name,
VersionImportJobID: job.ID,
})
if err != nil {
return err
}
spin.Stop()
logs, err := client.FollowProvisionerJobLogsAfter(context.Background(), organization.Name, job.ID, time.Time{})
_, err = prompt(cmd, &promptui.Prompt{
Label: "Create project?",
IsConfirm: true,
Default: "y",
})
if err != nil {
if errors.Is(err, promptui.ErrAbort) {
return nil
}
return err
}
for {
log, ok := <-logs
if !ok {
break
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s The %s project has been created!\n", color.HiBlackString(">"), color.HiCyanString(project.Name))
_, err = prompt(cmd, &promptui.Prompt{
Label: "Create a new workspace?",
IsConfirm: true,
Default: "y",
})
if err != nil {
if errors.Is(err, promptui.ErrAbort) {
return nil
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", color.HiGreenString("[parse]"), log.Output)
return err
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Create project %q!\n", name)
return nil
},
}
currentDirectory, _ := os.Getwd()
cmd.Flags().StringVarP(&directory, "directory", "d", currentDirectory, "Specify the directory to create from")
cmd.Flags().StringVarP(&provisioner, "provisioner", "p", "terraform", "Customize the provisioner backend")
// This is for testing!
err := cmd.Flags().MarkHidden("provisioner")
if err != nil {
panic(err)
}
return cmd
}
func validateProjectVersionSource(cmd *cobra.Command, client *codersdk.Client, organization coderd.Organization, provisioner database.ProvisionerType, directory string, parameters ...coderd.CreateParameterValueRequest) (*coderd.ProvisionerJob, error) {
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
spin.Writer = cmd.OutOrStdout()
spin.Suffix = " Uploading current directory..."
err := spin.Color("fgHiGreen")
if err != nil {
return nil, err
}
spin.Start()
defer spin.Stop()
tarData, err := tarDirectory(directory)
if err != nil {
return nil, err
}
resp, err := client.UploadFile(cmd.Context(), codersdk.ContentTypeTar, tarData)
if err != nil {
return nil, err
}
before := time.Now()
job, err := client.CreateProjectImportJob(cmd.Context(), organization.Name, coderd.CreateProjectImportJobRequest{
StorageMethod: database.ProvisionerStorageMethodFile,
StorageSource: resp.Hash,
Provisioner: provisioner,
ParameterValues: parameters,
})
if err != nil {
return nil, err
}
spin.Suffix = " Waiting for the import to complete..."
logs, err := client.ProjectImportJobLogsAfter(cmd.Context(), organization.Name, job.ID, before)
if err != nil {
return nil, err
}
logBuffer := make([]coderd.ProvisionerJobLog, 0, 64)
for {
log, ok := <-logs
if !ok {
break
}
logBuffer = append(logBuffer, log)
}
job, err = client.ProjectImportJob(cmd.Context(), organization.Name, job.ID)
if err != nil {
return nil, err
}
parameterSchemas, err := client.ProjectImportJobSchemas(cmd.Context(), organization.Name, job.ID)
if err != nil {
return nil, err
}
parameterValues, err := client.ProjectImportJobParameters(cmd.Context(), organization.Name, job.ID)
if err != nil {
return nil, err
}
spin.Stop()
if provisionerd.IsMissingParameterError(job.Error) {
valuesBySchemaID := map[string]coderd.ComputedParameterValue{}
for _, parameterValue := range parameterValues {
valuesBySchemaID[parameterValue.SchemaID.String()] = parameterValue
}
for _, parameterSchema := range parameterSchemas {
_, ok := valuesBySchemaID[parameterSchema.ID.String()]
if ok {
continue
}
if parameterSchema.Name == parameter.CoderWorkspaceTransition {
continue
}
value, err := prompt(cmd, &promptui.Prompt{
Label: fmt.Sprintf("Enter value for %s:", color.HiCyanString(parameterSchema.Name)),
})
if err != nil {
return nil, err
}
parameters = append(parameters, coderd.CreateParameterValueRequest{
Name: parameterSchema.Name,
SourceValue: value,
SourceScheme: database.ParameterSourceSchemeData,
DestinationScheme: parameterSchema.DefaultDestinationScheme,
})
}
return validateProjectVersionSource(cmd, client, organization, provisioner, directory, parameters...)
}
if job.Status != coderd.ProvisionerJobStatusSucceeded {
for _, log := range logBuffer {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", color.HiGreenString("[tf]"), log.Output)
}
return nil, xerrors.New(job.Error)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Successfully imported project source!\n", color.HiGreenString("✓"))
resources, err := client.ProjectImportJobResources(cmd.Context(), organization.Name, job.ID)
if err != nil {
return nil, err
}
return &job, displayProjectImportInfo(cmd, parameterSchemas, parameterValues, resources)
}
func tarDirectory(directory string) ([]byte, error) {
+102
View File
@@ -0,0 +1,102 @@
//go:build !windows
package cli_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/database"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
)
func TestProjectCreate(t *testing.T) {
t.Parallel()
t.Run("NoParameters", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
coderdtest.CreateInitialUser(t, client)
source := clitest.CreateProjectVersionSource(t, &echo.Responses{
Parse: echo.ParseComplete,
Provision: echo.ProvisionComplete,
})
cmd, root := clitest.New(t, "projects", "create", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
clitest.SetupConfig(t, client, root)
_ = coderdtest.NewProvisionerDaemon(t, client)
console := clitest.NewConsole(t, cmd)
closeChan := make(chan struct{})
go func() {
err := cmd.Execute()
require.NoError(t, err)
close(closeChan)
}()
matches := []string{
"organization?", "y",
"name?", "test-project",
"project?", "y",
"created!", "n",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
_, err := console.ExpectString(match)
require.NoError(t, err)
_, err = console.SendLine(value)
require.NoError(t, err)
}
<-closeChan
})
t.Run("Parameter", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
coderdtest.CreateInitialUser(t, client)
source := clitest.CreateProjectVersionSource(t, &echo.Responses{
Parse: []*proto.Parse_Response{{
Type: &proto.Parse_Response_Complete{
Complete: &proto.Parse_Complete{
ParameterSchemas: []*proto.ParameterSchema{{
Name: "somevar",
DefaultDestination: &proto.ParameterDestination{
Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE,
},
}},
},
},
}},
Provision: echo.ProvisionComplete,
})
cmd, root := clitest.New(t, "projects", "create", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
clitest.SetupConfig(t, client, root)
coderdtest.NewProvisionerDaemon(t, client)
console := clitest.NewConsole(t, cmd)
closeChan := make(chan struct{})
go func() {
err := cmd.Execute()
require.NoError(t, err)
close(closeChan)
}()
matches := []string{
"organization?", "y",
"name?", "test-project",
"somevar:", "value",
"project?", "y",
"created!", "n",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
_, err := console.ExpectString(match)
require.NoError(t, err)
_, err = console.SendLine(value)
require.NoError(t, err)
}
<-closeChan
})
}
+53 -2
View File
@@ -1,14 +1,22 @@
package cli
import (
"fmt"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/xlab/treeprint"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd"
"github.com/coder/coder/database"
)
func projects() *cobra.Command {
cmd := &cobra.Command{
Use: "projects",
Long: "Testing something",
Use: "projects",
Aliases: []string{"project"},
Example: `
- Create a project for developers to create workspaces
@@ -28,3 +36,46 @@ func projects() *cobra.Command {
return cmd
}
func displayProjectImportInfo(cmd *cobra.Command, parameterSchemas []coderd.ParameterSchema, parameterValues []coderd.ComputedParameterValue, resources []coderd.ProjectImportJobResource) error {
schemaByID := map[string]coderd.ParameterSchema{}
for _, schema := range parameterSchemas {
schemaByID[schema.ID.String()] = schema
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n %s\n\n", color.HiBlackString("Parameters"))
for _, value := range parameterValues {
schema, ok := schemaByID[value.SchemaID.String()]
if !ok {
return xerrors.Errorf("schema not found: %s", value.Name)
}
displayValue := value.SourceValue
if !schema.RedisplayValue {
displayValue = "<redacted>"
}
output := fmt.Sprintf("%s %s %s", color.HiCyanString(value.Name), color.HiBlackString("="), displayValue)
if value.DefaultSourceValue {
output += " (default value)"
} else if value.Scope != database.ParameterScopeImportJob {
output += fmt.Sprintf(" (inherited from %s)", value.Scope)
}
root := treeprint.NewWithRoot(output)
if schema.Description != "" {
root.AddBranch(fmt.Sprintf("%s\n%s", color.HiBlackString("Description"), schema.Description))
}
if schema.AllowOverrideSource {
root.AddBranch(fmt.Sprintf("%s Users can customize this value!", color.HiYellowString("+")))
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+strings.Join(strings.Split(root.String(), "\n"), "\n "))
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s\n\n", color.HiBlackString("Resources"))
for _, resource := range resources {
transition := color.HiGreenString("start")
if resource.Transition == database.WorkspaceTransitionStop {
transition = color.HiRedString("stop")
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s %s on %s\n\n", color.HiCyanString(resource.Type), color.HiCyanString(resource.Name), transition)
}
return nil
}
+17 -2
View File
@@ -69,6 +69,8 @@ func Root() *cobra.Command {
return cmd
}
// createClient returns a new client from the command context.
// The configuration directory will be read from the global flag.
func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
root := createConfig(cmd)
rawURL, err := root.URL().Read()
@@ -84,9 +86,11 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
return nil, err
}
client := codersdk.New(serverURL)
return client, client.SetSessionToken(token)
client.SessionToken = token
return client, nil
}
// currentOrganization returns the currently active organization for the authenticated user.
func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (coderd.Organization, error) {
orgs, err := client.UserOrganizations(cmd.Context(), "me")
if err != nil {
@@ -97,6 +101,7 @@ func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (coderd.Or
return orgs[0], nil
}
// createConfig consumes the global configuration flag to produce a config root.
func createConfig(cmd *cobra.Command) config.Root {
globalRoot, err := cmd.Flags().GetString(varGlobalConfig)
if err != nil {
@@ -116,7 +121,7 @@ func isTTY(reader io.Reader) bool {
return isatty.IsTerminal(file.Fd())
}
func runPrompt(cmd *cobra.Command, prompt *promptui.Prompt) (string, error) {
func prompt(cmd *cobra.Command, prompt *promptui.Prompt) (string, error) {
var ok bool
prompt.Stdin, ok = cmd.InOrStdin().(io.ReadCloser)
if !ok {
@@ -161,6 +166,16 @@ func runPrompt(cmd *cobra.Command, prompt *promptui.Prompt) (string, error) {
Invalid: invalid,
Valid: valid,
}
oldValidate := prompt.Validate
if oldValidate != nil {
// Override the validate function to pass our default!
prompt.Validate = func(s string) error {
if s == "" {
s = defaultValue
}
return oldValidate(s)
}
}
value, err := prompt.Run()
if value == "" && !prompt.IsConfirm {
value = defaultValue
+131
View File
@@ -0,0 +1,131 @@
package cli
import (
"errors"
"fmt"
"time"
"github.com/fatih/color"
"github.com/google/uuid"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd"
"github.com/coder/coder/database"
)
func workspaceCreate() *cobra.Command {
cmd := &cobra.Command{
Use: "create <project> [name]",
Short: "Create a workspace from a project",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
organization, err := currentOrganization(cmd, client)
if err != nil {
return err
}
var name string
if len(args) >= 2 {
name = args[1]
} else {
name, err = prompt(cmd, &promptui.Prompt{
Label: "What's your workspace's name?",
Validate: func(s string) error {
if s == "" {
return xerrors.Errorf("You must provide a name!")
}
workspace, _ := client.Workspace(cmd.Context(), "", s)
if workspace.ID.String() != uuid.Nil.String() {
return xerrors.New("A workspace already exists with that name!")
}
return nil
},
})
if err != nil {
if errors.Is(err, promptui.ErrAbort) {
return nil
}
return err
}
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Previewing project create...\n", color.HiBlackString(">"))
project, err := client.Project(cmd.Context(), organization.Name, args[0])
if err != nil {
return err
}
projectVersion, err := client.ProjectVersion(cmd.Context(), organization.Name, project.Name, project.ActiveVersionID.String())
if err != nil {
return err
}
parameterSchemas, err := client.ProjectImportJobSchemas(cmd.Context(), organization.Name, projectVersion.ImportJobID)
if err != nil {
return err
}
parameterValues, err := client.ProjectImportJobParameters(cmd.Context(), organization.Name, projectVersion.ImportJobID)
if err != nil {
return err
}
resources, err := client.ProjectImportJobResources(cmd.Context(), organization.Name, projectVersion.ImportJobID)
if err != nil {
return err
}
err = displayProjectImportInfo(cmd, parameterSchemas, parameterValues, resources)
if err != nil {
return err
}
_, err = prompt(cmd, &promptui.Prompt{
Label: fmt.Sprintf("Create workspace %s?", color.HiCyanString(name)),
Default: "y",
IsConfirm: true,
})
if err != nil {
if errors.Is(err, promptui.ErrAbort) {
return nil
}
return err
}
workspace, err := client.CreateWorkspace(cmd.Context(), "", coderd.CreateWorkspaceRequest{
ProjectID: project.ID,
Name: name,
})
if err != nil {
return err
}
history, err := client.CreateWorkspaceHistory(cmd.Context(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{
ProjectVersionID: projectVersion.ID,
Transition: database.WorkspaceTransitionStart,
})
if err != nil {
return err
}
logs, err := client.WorkspaceProvisionJobLogsAfter(cmd.Context(), organization.Name, history.ProvisionJobID, time.Time{})
if err != nil {
return err
}
for {
log, ok := <-logs
if !ok {
break
}
_, _ = fmt.Printf("Terraform: %s\n", log.Output)
}
// This command is WIP, and output will change!
_, _ = fmt.Printf("Created workspace! %s\n", name)
return nil
},
}
return cmd
}
+64
View File
@@ -0,0 +1,64 @@
//go:build !windows
package cli_test
import (
"testing"
"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/stretchr/testify/require"
)
func TestWorkspaceCreate(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
user := coderdtest.CreateInitialUser(t, client)
_ = coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, &echo.Responses{
Parse: echo.ParseComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
}},
},
},
}},
})
coderdtest.AwaitProjectImportJob(t, client, user.Organization, job.ID)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
cmd, root := clitest.New(t, "workspaces", "create", project.Name)
clitest.SetupConfig(t, client, root)
console := clitest.NewConsole(t, cmd)
closeChan := make(chan struct{})
go func() {
err := cmd.Execute()
require.NoError(t, err)
close(closeChan)
}()
matches := []string{
"name?", "workspace-name",
"Create workspace", "y",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
_, err := console.ExpectString(match)
require.NoError(t, err)
_, err = console.SendLine(value)
require.NoError(t, err)
}
_, err := console.ExpectString("Create")
require.NoError(t, err)
<-closeChan
})
}
+1
View File
@@ -6,6 +6,7 @@ func workspaces() *cobra.Command {
cmd := &cobra.Command{
Use: "workspaces",
}
cmd.AddCommand(workspaceCreate())
return cmd
}