Skip to content

feat: Add CLI support for workspace build parameters #5768

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 16 commits into from
Jan 23, 2023
53 changes: 53 additions & 0 deletions cli/cliui/parameter.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,56 @@ func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.ParameterSchem

return value, nil
}

func RichParameter(cmd *cobra.Command, templateVersionParameter codersdk.TemplateVersionParameter) (string, error) {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), Styles.Bold.Render(templateVersionParameter.Name))
if templateVersionParameter.Description != "" {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+strings.TrimSpace(strings.Join(strings.Split(templateVersionParameter.Description, "\n"), "\n "))+"\n")
}

// TODO Implement full validation and show descriptions.
var err error
var value string
if len(templateVersionParameter.Options) > 0 {
// Move the cursor up a single line for nicer display!
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A")
value, err = Select(cmd, SelectOptions{
Options: templateVersionParameterOptionValues(templateVersionParameter),
Default: templateVersionParameter.DefaultValue,
HideSearch: true,
})
if err == nil {
_, _ = fmt.Fprintln(cmd.OutOrStdout())
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Prompt.String()+Styles.Field.Render(value))
}
} else {
text := "Enter a value"
if templateVersionParameter.DefaultValue != "" {
text += fmt.Sprintf(" (default: %q)", templateVersionParameter.DefaultValue)
}
text += ":"

value, err = Prompt(cmd, PromptOptions{
Text: Styles.Bold.Render(text),
})
value = strings.TrimSpace(value)
}
if err != nil {
return "", err
}

// If they didn't specify anything, use the default value if set.
if len(templateVersionParameter.Options) == 0 && value == "" {
value = templateVersionParameter.DefaultValue
}

return value, nil
}

func templateVersionParameterOptionValues(param codersdk.TemplateVersionParameter) []string {
var options []string
for _, opt := range param.Options {
options = append(options, opt.Value)
}
return options
}
131 changes: 104 additions & 27 deletions cli/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ import (

func create() *cobra.Command {
var (
parameterFile string
templateName string
startAt string
stopAfter time.Duration
workspaceName string
parameterFile string
richParameterFile string
templateName string
startAt string
stopAfter time.Duration
workspaceName string
)
cmd := &cobra.Command{
Annotations: workspaceCommand,
Expand Down Expand Up @@ -121,11 +122,12 @@ func create() *cobra.Command {
schedSpec = ptr.Ref(sched.String())
}

parameters, err := prepWorkspaceBuild(cmd, client, prepWorkspaceBuildArgs{
Template: template,
ExistingParams: []codersdk.Parameter{},
ParameterFile: parameterFile,
NewWorkspaceName: workspaceName,
buildParams, err := prepWorkspaceBuild(cmd, client, prepWorkspaceBuildArgs{
Template: template,
ExistingParams: []codersdk.Parameter{},
ParameterFile: parameterFile,
RichParameterFile: richParameterFile,
NewWorkspaceName: workspaceName,
})
if err != nil {
return err
Expand All @@ -140,11 +142,12 @@ func create() *cobra.Command {
}

workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: template.ID,
Name: workspaceName,
AutostartSchedule: schedSpec,
TTLMillis: ptr.Ref(stopAfter.Milliseconds()),
ParameterValues: parameters,
TemplateID: template.ID,
Name: workspaceName,
AutostartSchedule: schedSpec,
TTLMillis: ptr.Ref(stopAfter.Milliseconds()),
ParameterValues: buildParams.parameters,
RichParameterValues: buildParams.richParameters,
})
if err != nil {
return err
Expand All @@ -163,26 +166,40 @@ func create() *cobra.Command {
cliui.AllowSkipPrompt(cmd)
cliflag.StringVarP(cmd.Flags(), &templateName, "template", "t", "CODER_TEMPLATE_NAME", "", "Specify a template name.")
cliflag.StringVarP(cmd.Flags(), &parameterFile, "parameter-file", "", "CODER_PARAMETER_FILE", "", "Specify a file path with parameter values.")
cliflag.StringVarP(cmd.Flags(), &richParameterFile, "rich-parameter-file", "", "CODER_RICH_PARAMETER_FILE", "", "Specify a file path with values for rich parameters defined in the template.")
cliflag.StringVarP(cmd.Flags(), &startAt, "start-at", "", "CODER_WORKSPACE_START_AT", "", "Specify the workspace autostart schedule. Check `coder schedule start --help` for the syntax.")
cliflag.DurationVarP(cmd.Flags(), &stopAfter, "stop-after", "", "CODER_WORKSPACE_STOP_AFTER", 8*time.Hour, "Specify a duration after which the workspace should shut down (e.g. 8h).")
return cmd
}

type prepWorkspaceBuildArgs struct {
Template codersdk.Template
ExistingParams []codersdk.Parameter
ParameterFile string
NewWorkspaceName string
Template codersdk.Template
ExistingParams []codersdk.Parameter
ParameterFile string
ExistingRichParams []codersdk.WorkspaceBuildParameter
RichParameterFile string
NewWorkspaceName string

UpdateWorkspace bool
}

type buildParameters struct {
// Parameters contains legacy parameters stored in /parameters.
parameters []codersdk.CreateParameterRequest
// Rich parameters stores values for build parameters annotated with description, icon, type, etc.
richParameters []codersdk.WorkspaceBuildParameter
}

// prepWorkspaceBuild will ensure a workspace build will succeed on the latest template version.
// Any missing params will be prompted to the user.
func prepWorkspaceBuild(cmd *cobra.Command, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.CreateParameterRequest, error) {
// Any missing params will be prompted to the user. It supports legacy and rich parameters.
func prepWorkspaceBuild(cmd *cobra.Command, client *codersdk.Client, args prepWorkspaceBuildArgs) (*buildParameters, error) {
ctx := cmd.Context()
templateVersion, err := client.TemplateVersion(ctx, args.Template.ActiveVersionID)
if err != nil {
return nil, err
}

// Legacy parameters
parameterSchemas, err := client.TemplateVersionSchema(ctx, templateVersion.ID)
if err != nil {
return nil, err
Expand All @@ -200,7 +217,7 @@ func prepWorkspaceBuild(cmd *cobra.Command, client *codersdk.Client, args prepWo
}
}
disclaimerPrinted := false
parameters := make([]codersdk.CreateParameterRequest, 0)
legacyParameters := make([]codersdk.CreateParameterRequest, 0)
PromptParamLoop:
for _, parameterSchema := range parameterSchemas {
if !parameterSchema.AllowOverrideSource {
Expand All @@ -227,19 +244,76 @@ PromptParamLoop:
return nil, err
}

parameters = append(parameters, codersdk.CreateParameterRequest{
legacyParameters = append(legacyParameters, codersdk.CreateParameterRequest{
Name: parameterSchema.Name,
SourceValue: parameterValue,
SourceScheme: codersdk.ParameterSourceSchemeData,
DestinationScheme: parameterSchema.DefaultDestinationScheme,
})
}
_, _ = fmt.Fprintln(cmd.OutOrStdout())

if disclaimerPrinted {
_, _ = fmt.Fprintln(cmd.OutOrStdout())
}

// Rich parameters
templateVersionParameters, err := client.TemplateVersionRichParameters(cmd.Context(), templateVersion.ID)
if err != nil {
return nil, xerrors.Errorf("get template version rich parameters: %w", err)
}

parameterMapFromFile = map[string]string{}
useParamFile = false
if args.RichParameterFile != "" {
useParamFile = true
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the rich parameter file.")+"\r\n")
parameterMapFromFile, err = createParameterMapFromFile(args.RichParameterFile)
if err != nil {
return nil, err
}
}
disclaimerPrinted = false
richParameters := make([]codersdk.WorkspaceBuildParameter, 0)
PromptRichParamLoop:
for _, templateVersionParameter := range templateVersionParameters {
if !disclaimerPrinted {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has customizable parameters. Values can be changed after create, but may have unintended side effects (like data loss).")+"\r\n")
disclaimerPrinted = true
}

// Param file is all or nothing
if !useParamFile {
for _, e := range args.ExistingRichParams {
if e.Name == templateVersionParameter.Name {
// If the param already exists, we do not need to prompt it again.
// The workspace scope will reuse params for each build.
continue PromptRichParamLoop
}
}
}

if args.UpdateWorkspace && !templateVersionParameter.Mutable {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Warn.Render(fmt.Sprintf(`Parameter %q is not mutable, so can't be customized after workspace creation.`, templateVersionParameter.Name)))
continue
}

parameterValue, err := getWorkspaceBuildParameterValueFromMapOrInput(cmd, parameterMapFromFile, templateVersionParameter)
if err != nil {
return nil, err
}

richParameters = append(richParameters, *parameterValue)
}

if disclaimerPrinted {
_, _ = fmt.Fprintln(cmd.OutOrStdout())
}

// Run a dry-run with the given parameters to check correctness
dryRun, err := client.CreateTemplateVersionDryRun(cmd.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
WorkspaceName: args.NewWorkspaceName,
ParameterValues: parameters,
WorkspaceName: args.NewWorkspaceName,
ParameterValues: legacyParameters,
RichParameterValues: richParameters,
})
if err != nil {
return nil, xerrors.Errorf("begin workspace dry-run: %w", err)
Expand Down Expand Up @@ -279,5 +353,8 @@ PromptParamLoop:
return nil, err
}

return parameters, nil
return &buildParameters{
parameters: legacyParameters,
richParameters: richParameters,
}, nil
}
118 changes: 118 additions & 0 deletions cli/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,124 @@ func TestCreate(t *testing.T) {
})
}

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

const (
firstParameterName = "first_parameter"
firstParameterDescription = "This is first parameter"
firstParameterValue = "1"

secondParameterName = "second_parameter"
secondParameterDescription = "This is second parameter"
secondParameterValue = "2"

immutableParameterName = "third_parameter"
immutableParameterDescription = "This is not mutable parameter"
immutableParameterValue = "3"
)

echoResponses := &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Provision_Response{
{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Parameters: []*proto.RichParameter{
{Name: firstParameterName, Description: firstParameterDescription, Mutable: true},
{Name: secondParameterName, Description: secondParameterDescription, Mutable: true},
{Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false},
},
},
},
}},
ProvisionApply: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{},
},
}},
}

t.Run("InputParameters", func(t *testing.T) {
t.Parallel()

client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)

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

cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
go func() {
defer close(doneChan)
err := cmd.Execute()
assert.NoError(t, err)
}()

matches := []string{
firstParameterDescription, firstParameterValue,
secondParameterDescription, secondParameterValue,
immutableParameterDescription, immutableParameterValue,
"Confirm create?", "yes",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
}
<-doneChan
})

t.Run("RichParametersFile", func(t *testing.T) {
t.Parallel()

client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)

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

tempDir := t.TempDir()
removeTmpDirUntilSuccessAfterTest(t, tempDir)
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
_, _ = parameterFile.WriteString(
firstParameterName + ": " + firstParameterValue + "\n" +
secondParameterName + ": " + secondParameterValue + "\n" +
immutableParameterName + ": " + immutableParameterValue)
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
clitest.SetupConfig(t, client, root)

doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
go func() {
defer close(doneChan)
err := cmd.Execute()
assert.NoError(t, err)
}()

matches := []string{
"Confirm create?", "yes",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
}
<-doneChan
})
}

func createTestParseResponseWithDefault(defaultValue string) []*proto.Parse_Response {
return []*proto.Parse_Response{{
Type: &proto.Parse_Response_Complete{
Expand Down
Loading