Skip to content

feat: Add "coder projects create" command #246

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 21 commits into from
Feb 12, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Refactor parameters to enable schema matching
  • Loading branch information
kylecarbs committed Feb 10, 2022
commit 94eb484655a33d5e5d6899886330e84c83eae9c3
19 changes: 19 additions & 0 deletions cli/clitest/clitest.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ package clitest

import (
"bufio"
"context"
"io"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/require"

"github.com/coder/coder/cli"
"github.com/coder/coder/cli/config"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
)

func New(t *testing.T, args ...string) (*cobra.Command, config.Root) {
Expand All @@ -19,6 +24,20 @@ func New(t *testing.T, args ...string) (*cobra.Command, config.Root) {
return cmd, root
}

func CreateInitialUser(t *testing.T, client *codersdk.Client, root config.Root) coderd.CreateInitialUserRequest {
user := coderdtest.CreateInitialUser(t, client)
resp, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Email: user.Email,
Password: user.Password,
})
require.NoError(t, err)
err = root.Session().Write(resp.SessionToken)
require.NoError(t, err)
err = root.URL().Write(client.URL.String())
require.NoError(t, err)
return user
}

func StdoutLogs(t *testing.T) io.Writer {
reader, writer := io.Pipe()
scanner := bufio.NewScanner(reader)
Expand Down
93 changes: 81 additions & 12 deletions cli/projectcreate.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import (
)

func projectCreate() *cobra.Command {
return &cobra.Command{
var (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd propose structuring these commands as coder <verb> <subject> similar to kubectl. When you demoed this command to me, that was what you had typed the first time, too, suggesting that it feels more "natural" - it also reads better that way, in my opinion.

For example: coder create project

The nice thing about kubectl is that most of the commands follow an identical pattern, so kubectl get pods, kubectl get deployments etc feel natural. Putting the verb after makes it more likely that they will diverge, which is always unexpected (e.g. cases where kubectl project create is a valid command but kubectl workspace create is not)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a significantly more human approach. I'm trying to think of edge-cases in which that won't work, but I'm yet to find any!

Maybe discoverability would be hurt? eg. adding the root verb for a bespoke action a specific resource has could get really messy. I'm not sure how frequently we'll need to do that though. ssh and plan are the only odd ones I can think of.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kubectl has exec and logs and a few other commands that don't follow the pattern, so we could have coder ssh <workspace> or coder plan <workspace> (if we anticipate that there will be other things we'll need to SSH into, we could also do coder ssh workspace <workspace> or similar, of course - we don't have to implement coder ssh project, since that doesn't make much sense)

Thinking about it now, one reason the coder create pattern might be helpful is because of persistent flags. kubectl has a --file flag that applies to all create verbs, so you can implement it as a persistent flag on create. On the other hand, if we have separate create leaf commands, then we'd have to make sure we consistently implement --file for everything individually

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also always make these changes after merging this PR, it's not urgent, but would be nice to do it before we publish things, because then we're on the hook to maintain stability of the interface

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We lose specificity and customization in output when we standardize using verbs first. eg. it'd be a bit weird for a user to run:

coder describe workspace test

and expect the same visual output as

coder describe user test

Maybe it's good to force us down a consistency route there though.

As you mentioned, we can polish this later. It's definitely a nice standardization forcing-function that provides consistency to our CLI experience!

directory string
)
cmd := &cobra.Command{
Use: "create",
Short: "Create a project from the current directory",
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -33,23 +36,17 @@ func projectCreate() *cobra.Command {
if err != nil {
return err
}

workingDir, err := os.Getwd()
if err != nil {
return err
}

_, err = runPrompt(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 {
return err
}

name, err := runPrompt(cmd, &promptui.Prompt{
Default: filepath.Base(workingDir),
Default: filepath.Base(directory),
Label: "What's your project's name?",
Validate: func(s string) error {
_, err = client.Project(cmd.Context(), organization.Name, s)
Expand All @@ -63,12 +60,12 @@ func projectCreate() *cobra.Command {
return err
}

spin := spinner.New(spinner.CharSets[0], 50*time.Millisecond)
spin := spinner.New(spinner.CharSets[0], 25*time.Millisecond)
spin.Suffix = " Uploading current directory..."
spin.Start()
defer spin.Stop()

bytes, err := tarDirectory(workingDir)
bytes, err := tarDirectory(directory)
if err != nil {
return err
}
Expand All @@ -84,6 +81,12 @@ func projectCreate() *cobra.Command {
Provisioner: database.ProvisionerTypeTerraform,
// SkipResources on first import to detect variables defined by the project.
SkipResources: true,
// ParameterValues: []coderd.CreateParameterValueRequest{{
// Name: "aws_access_key",
// SourceValue: "tomato",
// SourceScheme: database.ParameterSourceSchemeData,
// DestinationScheme: database.ParameterDestinationSchemeProvisionerVariable,
// }},
})
if err != nil {
return err
Expand All @@ -102,19 +105,85 @@ func projectCreate() *cobra.Command {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", color.HiGreenString("[parse]"), log.Output)
}

_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Parsed project source... displaying parameters:")

schemas, err := client.ProvisionerJobParameterSchemas(cmd.Context(), organization.Name, job.ID)
if err != nil {
return err
}

values, err := client.ProvisionerJobParameterValues(cmd.Context(), organization.Name, job.ID)
if err != nil {
return err
}
valueBySchemaID := map[string]coderd.ComputedParameterValue{}
for _, value := range values {
valueBySchemaID[value.SchemaID.String()] = value
}

for _, schema := range schemas {
fmt.Printf("Schema: %+v\n", schema)
if value, ok := valueBySchemaID[schema.ID.String()]; ok {
fmt.Printf("Value for: %s %s\n", value.Name, value.SourceValue)
continue
}
fmt.Printf("No value for: %s\n", schema.Name)
}

// schemas, err := client.ProvisionerJobParameterSchemas(cmd.Context(), organization.Name, job.ID)
// if err != nil {
// return err
// }
// _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n %s\n\n", color.HiBlackString("Parameters"))

// for _, param := range params {
// if param.Value == nil {
// _, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s = must be set\n", color.HiRedString(param.Schema.Name))
// continue
// }
// value := param.Value.DestinationValue
// if !param.Schema.RedisplayValue {
// value = "<redacted>"
// }
// output := fmt.Sprintf(" %s = %s", color.HiGreenString(param.Value.SourceValue), color.CyanString(value))
// param.Value.DefaultSourceValue = false
// param.Value.Scope = database.ParameterScopeOrganization
// param.Value.ScopeID = organization.ID
// if param.Value.DefaultSourceValue {
// output += " (default value)"
// } else {
// output += fmt.Sprintf(" (inherited from %s)", param.Value.Scope)
// }
// root := treeprint.NewWithRoot(output)
// root.AddNode(color.HiBlackString("Description") + "\n" + param.Schema.Description)
// fmt.Fprintln(cmd.OutOrStdout(), strings.Join(strings.Split(root.String(), "\n"), "\n "))
// }

// for _, param := range params {
// if param.Value != nil {
// continue
// }

// value, err := runPrompt(cmd, &promptui.Prompt{
// Label: "Specify value for " + color.HiCyanString(param.Schema.Name),
// Validate: func(s string) error {
// // param.Schema.Vali
// return nil
// },
// })
// if err != nil {
// continue
// }
// fmt.Printf(": %s\n", value)
// }

_, _ = 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")

return cmd
}

func tarDirectory(directory string) ([]byte, error) {
Expand Down
42 changes: 42 additions & 0 deletions cli/projectcreate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cli_test

import (
"testing"

"github.com/Netflix/go-expect"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/stretchr/testify/require"
)

func TestProjectCreate(t *testing.T) {
t.Parallel()
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)
directory := t.TempDir()
cmd, root := clitest.New(t, "projects", "create", "--directory", directory)
_ = clitest.CreateInitialUser(t, client, root)
cmd.SetIn(console.Tty())
cmd.SetOut(console.Tty())
go func() {
err := cmd.Execute()
require.NoError(t, err)
}()

matches := []string{
"organization?", "y",
"name?", "",
}
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)
}
})
}
5 changes: 3 additions & 2 deletions cli/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import (

func projects() *cobra.Command {
cmd := &cobra.Command{
Use: "projects",
Long: "Testing something",
Use: "projects",
Aliases: []string{"project"},
Long: "Testing something",
Example: `
- Create a project for developers to create workspaces

Expand Down
3 changes: 2 additions & 1 deletion coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ func New(options *Options) http.Handler {
r.Route("/{provisionerjob}", func(r chi.Router) {
r.Use(httpmw.ExtractProvisionerJobParam(options.Database))
r.Get("/", api.provisionerJobByOrganization)
r.Get("/parameters", api.provisionerJobParametersByID)
r.Get("/schemas", api.provisionerJobParameterSchemasByID)
r.Get("/computed", api.provisionerJobComputedParametersByID)
r.Get("/logs", api.provisionerJobLogsByID)
})
})
Expand Down
18 changes: 18 additions & 0 deletions coderd/parameter/compute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,4 +201,22 @@ func TestCompute(t *testing.T) {
require.Len(t, computed, 1)
require.Equal(t, false, computed[0].DefaultSourceValue)
})

t.Run("HideRedisplay", func(t *testing.T) {
t.Parallel()
db := databasefake.New()
scope := generateScope()
_ = generateParameter(t, db, parameterOptions{
ProjectImportJobID: scope.ProjectImportJobID,
DefaultDestinationScheme: database.ParameterDestinationSchemeProvisionerVariable,
})
computed, err := parameter.Compute(context.Background(), db, scope, &parameter.ComputeOptions{
HideRedisplayValues: true,
})
require.NoError(t, err)
require.Len(t, computed, 1)
computedValue := computed[0]
require.True(t, computedValue.DefaultSourceValue)
require.Equal(t, computedValue.SourceValue, "")
})
}
Loading