Ctrl K
ComunidadGuías…

cli/create.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package cli

import (
	"context"
	"errors"
	"fmt"
	"io"
	"slices"
	"strings"
	"time"

	"github.com/google/uuid"
	"golang.org/x/xerrors"

	"github.com/coder/coder/v2/cli/cliui"
	"github.com/coder/coder/v2/cli/cliutil"
	"github.com/coder/coder/v2/coderd/util/ptr"
	"github.com/coder/coder/v2/coderd/util/slice"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/pretty"
	"github.com/coder/serpent"
)

// PresetNone represents the special preset value "none".
// It is used when a user runs `create --preset none`,
// indicating that the CLI should not apply any preset.
const PresetNone = "none"

var ErrNoPresetFound = xerrors.New("no preset found")

type CreateOptions struct {
	BeforeCreate func(ctx context.Context, client *codersdk.Client, template codersdk.Template, templateVersionID uuid.UUID) error
	AfterCreate  func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error
}

func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
	var (
		templateName    string
		templateVersion string
		presetName      string
		startAt         string
		stopAfter       time.Duration
		workspaceName   string

		parameterFlags     workspaceParameterFlags
		autoUpdates        string
		copyParametersFrom string
		noWait             bool
		// Organization context is only required if more than 1 template
		// shares the same name across multiple organizations.
		orgContext = NewOrganizationContext()
	)
	cmd := &serpent.Command{
		Annotations: workspaceCommand,
		Use:         "create [workspace]",
		Short:       "Create a workspace",
		Long: FormatExamples(
			Example{
				Description: "Create a workspace for another user (if you have permission)",
				Command:     "coder create <username>/<workspace_name>",
			},
		),
		Handler: func(inv *serpent.Invocation) error {
			client, err := r.InitClient(inv)
			if err != nil {
				return err
			}

			workspaceOwner := codersdk.Me
			if len(inv.Args) >= 1 {
				workspaceOwner, workspaceName, err = codersdk.SplitWorkspaceIdentifier(inv.Args[0])
				if err != nil {
					return err
				}
			}

			if workspaceName == "" {
				workspaceName, err = cliui.Prompt(inv, cliui.PromptOptions{
					Text: "Specify a name for your workspace:",
					Validate: func(workspaceName string) error {
						err = codersdk.NameValid(workspaceName)
						if err != nil {
							return xerrors.Errorf("workspace name %q is invalid: %w", workspaceName, err)
						}
						_, err = client.WorkspaceByOwnerAndName(inv.Context(), workspaceOwner, workspaceName, codersdk.WorkspaceOptions{})
						if err == nil {
							return xerrors.Errorf("a workspace already exists named %q", workspaceName)
						}
						return nil
					},
				})
				if err != nil {
					return err
				}
			}
			err = codersdk.NameValid(workspaceName)
			if err != nil {
				return xerrors.Errorf("workspace name %q is invalid: %w", workspaceName, err)
			}
			_, err = client.WorkspaceByOwnerAndName(inv.Context(), workspaceOwner, workspaceName, codersdk.WorkspaceOptions{})
			if err == nil {
				return xerrors.Errorf("a workspace already exists named %q", workspaceName)
			}

			var sourceWorkspace codersdk.Workspace
			if copyParametersFrom != "" {
				sourceWorkspaceOwner, sourceWorkspaceName, err := codersdk.SplitWorkspaceIdentifier(copyParametersFrom)
				if err != nil {
					return err
				}

				sourceWorkspace, err = client.WorkspaceByOwnerAndName(inv.Context(), sourceWorkspaceOwner, sourceWorkspaceName, codersdk.WorkspaceOptions{})
				if err != nil {
					return xerrors.Errorf("get source workspace: %w", err)
				}

				_, _ = fmt.Fprintf(inv.Stdout, "Coder will use the same template %q as the source workspace.\n", sourceWorkspace.TemplateName)
				templateName = sourceWorkspace.TemplateName
			}

			var template codersdk.Template
			var templateVersionID uuid.UUID
			switch {
			case templateName == "":
				_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:"))

				templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{})
				if err != nil {
					return err
				}

				slices.SortFunc(templates, func(a, b codersdk.Template) int {
					return slice.Descending(a.ActiveUserCount, b.ActiveUserCount)
				})

				templateNames := make([]string, 0, len(templates))
				templateByName := make(map[string]codersdk.Template, len(templates))

				// If more than 1 organization exists in the list of templates,
				// then include the organization name in the select options.
				uniqueOrganizations := make(map[uuid.UUID]bool)
				for _, template := range templates {
					uniqueOrganizations[template.OrganizationID] = true
				}

				for _, template := range templates {
					templateName := template.Name
					if len(uniqueOrganizations) > 1 {
						templateName += cliui.Placeholder(
							fmt.Sprintf(
								" (%s)",
								template.OrganizationName,
							),
						)
					}

					if template.ActiveUserCount > 0 {
						templateName += cliui.Placeholder(
							fmt.Sprintf(
								" used by %s",
								formatActiveDevelopers(template.ActiveUserCount),
							),
						)
					}

					templateNames = append(templateNames, templateName)
					templateByName[templateName] = template
				}

				// Move the cursor up a single line for nicer display!
				option, err := cliui.Select(inv, cliui.SelectOptions{
					Options:    templateNames,
					HideSearch: true,
				})
				if err != nil {
					return err
				}

				template = templateByName[option]
				templateVersionID = template.ActiveVersionID
			case sourceWorkspace.LatestBuild.TemplateVersionID != uuid.Nil:
				template, err = client.Template(inv.Context(), sourceWorkspace.TemplateID)
				if err != nil {
					return xerrors.Errorf("get template by name: %w", err)
				}
				templateVersionID = sourceWorkspace.LatestBuild.TemplateVersionID
			default:
				templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{
					ExactName: templateName,
				})
				if err != nil {
					return xerrors.Errorf("get template by name: %w", err)
				}
				if len(templates) == 0 {
					return xerrors.Errorf("no template found with the name %q", templateName)
				}

				if len(templates) > 1 {
					templateOrgs := []string{}
					for _, tpl := range templates {
						templateOrgs = append(templateOrgs, tpl.OrganizationName)
					}

					selectedOrg, err := orgContext.Selected(inv, client)
					if err != nil {
						return xerrors.Errorf("multiple templates found with the name %q, use `--org=<organization_name>` to specify which template by that name to use. Organizations available: %s", templateName, strings.Join(templateOrgs, ", "))
					}

					index := slices.IndexFunc(templates, func(i codersdk.Template) bool {
						return i.OrganizationID == selectedOrg.ID
					})
					if index == -1 {
						return xerrors.Errorf("no templates found with the name %q in the organization %q. Templates by that name exist in organizations: %s. Use --org=<organization_name> to select one.", templateName, selectedOrg.Name, strings.Join(templateOrgs, ", "))
					}

					// remake the list with the only template selected
					templates = []codersdk.Template{templates[index]}
				}

				template = templates[0]
				templateVersionID = template.ActiveVersionID
			}

			if len(templateVersion) > 0 {
				versionID, err := resolveTemplateVersionID(inv.Context(), client, template.ID, template.Name, templateVersion)
				if err != nil {
					return err
				}
				templateVersionID = versionID
			}

			// If the user specified an organization via a flag or env var, the template **must**
			// be in that organization. Otherwise, we should throw an error.
			orgValue, orgValueSource := orgContext.ValueSource(inv)
			if orgValue != "" && orgValueSource != serpent.ValueSourceDefault && orgValueSource != serpent.ValueSourceNone {
				selectedOrg, err := orgContext.Selected(inv, client)
				if err != nil {
					return err
				}

				if template.OrganizationID != selectedOrg.ID {
					orgNameFormat := "'--org=%q'"
					if orgValueSource == serpent.ValueSourceEnv {
						orgNameFormat = "CODER_ORGANIZATION=%q"
					}

					return xerrors.Errorf("template is in organization %q, but %s was specified. Use %s to use this template",
						template.OrganizationName,
						fmt.Sprintf(orgNameFormat, selectedOrg.Name),
						fmt.Sprintf(orgNameFormat, template.OrganizationName),
					)
				}
			}

			var schedSpec *string
			if startAt != "" {
				sched, err := parseCLISchedule(startAt)
				if err != nil {
					return err
				}
				schedSpec = ptr.Ref(sched.String())
			}

			cliBuildParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters)
			if err != nil {
				return xerrors.Errorf("can't parse given parameter values: %w", err)
			}

			cliBuildParameterDefaults, err := asWorkspaceBuildParameters(parameterFlags.richParameterDefaults)
			if err != nil {
				return xerrors.Errorf("can't parse given parameter defaults: %w", err)
			}

			cliEphemeralParameters, err := asWorkspaceBuildParameters(parameterFlags.ephemeralParameters)
			if err != nil {
				return xerrors.Errorf("can't parse given ephemeral parameter values: %w", err)
			}

			var sourceWorkspaceParameters []codersdk.WorkspaceBuildParameter
			if copyParametersFrom != "" {
				sourceWorkspaceParameters, err = client.WorkspaceBuildParameters(inv.Context(), sourceWorkspace.LatestBuild.ID)
				if err != nil {
					return xerrors.Errorf("get source workspace build parameters: %w", err)
				}
			}

			// Get presets for the template version
			tvPresets, err := client.TemplateVersionPresets(inv.Context(), templateVersionID)
			if err != nil {
				return xerrors.Errorf("failed to get presets: %w", err)
			}

			var preset *codersdk.Preset
			var presetParameters []codersdk.WorkspaceBuildParameter

			// If the template has no presets, or the user explicitly used --preset none,
			// skip applying a preset
			if len(tvPresets) > 0 && strings.ToLower(presetName) != PresetNone {
				// Attempt to resolve which preset to use
				preset, err = resolvePreset(tvPresets, presetName)
				if err != nil {
					if !errors.Is(err, ErrNoPresetFound) {
						return xerrors.Errorf("unable to resolve preset: %w", err)
					}
					// If no preset found, prompt the user to choose a preset.
					// A nil preset means the user chose "None".
					if preset, err = promptPresetSelection(inv, tvPresets); err != nil {
						return xerrors.Errorf("unable to prompt user for preset: %w", err)
					}
				}
			}

			if preset != nil {
				// Convert preset parameters into workspace build parameters
				presetParameters = presetParameterAsWorkspaceBuildParameters(preset.Parameters)
				// Inform the user which preset was applied and its parameters
				displayAppliedPreset(inv, preset, presetParameters)
			} else {
				// Inform the user that no preset was applied
				_, _ = fmt.Fprintf(inv.Stdout, "%s\n", cliui.Bold("No preset applied."))
			}

			if opts.BeforeCreate != nil {
				err = opts.BeforeCreate(inv.Context(), client, template, templateVersionID)
				if err != nil {
					return xerrors.Errorf("before create: %w", err)
				}
			}

			richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
				Action:            WorkspaceCreate,
				TemplateVersionID: templateVersionID,
				NewWorkspaceName:  workspaceName,
				Owner:             workspaceOwner,

				PresetParameters:      presetParameters,
				RichParameterFile:     parameterFlags.richParameterFile,
				RichParameters:        cliBuildParameters,
				RichParameterDefaults: cliBuildParameterDefaults,

				PromptEphemeralParameters: parameterFlags.promptEphemeralParameters,
				EphemeralParameters:       cliEphemeralParameters,

				SourceWorkspaceParameters: sourceWorkspaceParameters,

				UseParameterDefaults: parameterFlags.useParameterDefaults,
			})
			if err != nil {
				return xerrors.Errorf("prepare build: %w", err)
			}

			_, err = cliui.Prompt(inv, cliui.PromptOptions{
				Text:      "Confirm create?",
				IsConfirm: true,
			})
			if err != nil {
				return err
			}

			var ttlMillis *int64
			if stopAfter > 0 {
				ttlMillis = new(stopAfter.Milliseconds())
			}

			req := codersdk.CreateWorkspaceRequest{
				TemplateVersionID:   templateVersionID,
				Name:                workspaceName,
				AutostartSchedule:   schedSpec,
				TTLMillis:           ttlMillis,
				RichParameterValues: richParameters,
				AutomaticUpdates:    codersdk.AutomaticUpdates(autoUpdates),
			}

			// If a preset exists, update the create workspace request's preset ID
			if preset != nil {
				req.TemplateVersionPresetID = preset.ID
			}

			workspace, err := client.CreateUserWorkspace(inv.Context(), workspaceOwner, req)
			if err != nil {
				return xerrors.Errorf("create workspace: %w", err)
			}

			cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job)

			if noWait {
				_, _ = fmt.Fprintf(inv.Stdout,
					"\nThe %s workspace has been created and is building in the background.\n",
					cliui.Keyword(workspace.Name),
				)
				return nil
			}

			err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, workspace.LatestBuild.ID)
			if err != nil {
				return xerrors.Errorf("watch build: %w", err)
			}

			_, _ = fmt.Fprintf(
				inv.Stdout,
				"\nThe %s workspace has been created at %s!\n",
				cliui.Keyword(workspace.Name),
				cliui.Timestamp(time.Now()),
			)

			if opts.AfterCreate != nil {
				err = opts.AfterCreate(inv.Context(), inv, client, workspace)
				if err != nil {
					return err
				}
			}

			return nil
		},
	}
	cmd.Options = append(cmd.Options,
		serpent.Option{
			Flag:          "template",
			FlagShorthand: "t",
			Env:           "CODER_TEMPLATE_NAME",
			Description:   "Specify a template name.",
			Value:         serpent.StringOf(&templateName),
		},
		serpent.Option{
			Flag:        "template-version",
			Env:         "CODER_TEMPLATE_VERSION",
			Description: "Specify a template version name or ID.",
			Value:       serpent.StringOf(&templateVersion),
		},
		serpent.Option{
			Flag:        "preset",
			Env:         "CODER_PRESET_NAME",
			Description: "Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used.",
			Value:       serpent.StringOf(&presetName),
		},
		serpent.Option{
			Flag:        "start-at",
			Env:         "CODER_WORKSPACE_START_AT",
			Description: "Specify the workspace autostart schedule. Check coder schedule start --help for the syntax.",
			Value:       serpent.StringOf(&startAt),
		},
		serpent.Option{
			Flag:        "stop-after",
			Env:         "CODER_WORKSPACE_STOP_AFTER",
			Description: "Specify a duration after which the workspace should shut down (e.g. 8h).",
			Value:       serpent.DurationOf(&stopAfter),
		},
		serpent.Option{
			Flag:        "automatic-updates",
			Env:         "CODER_WORKSPACE_AUTOMATIC_UPDATES",
			Description: "Specify automatic updates setting for the workspace (accepts 'always' or 'never').",
			Default:     string(codersdk.AutomaticUpdatesNever),
			Value:       serpent.StringOf(&autoUpdates),
		},
		serpent.Option{
			Flag:        "copy-parameters-from",
			Env:         "CODER_WORKSPACE_COPY_PARAMETERS_FROM",
			Description: "Specify the source workspace name to copy parameters from.",
			Value:       serpent.StringOf(&copyParametersFrom),
		},
		serpent.Option{
			Flag:        "no-wait",
			Env:         "CODER_CREATE_NO_WAIT",
			Description: "Return immediately after creating the workspace. The build will run in the background.",
			Value:       serpent.BoolOf(&noWait),
		},
		cliui.SkipPromptOption(),
	)
	cmd.Options = append(cmd.Options, parameterFlags.allOptions()...)

	orgContext.AttachOptions(cmd)
	return cmd
}

type prepWorkspaceBuildArgs struct {
	Action            WorkspaceCLIAction
	TemplateVersionID uuid.UUID
	NewWorkspaceName  string
	// The owner is required when evaluating dynamic parameters
	Owner string

	LastBuildParameters       []codersdk.WorkspaceBuildParameter
	SourceWorkspaceParameters []codersdk.WorkspaceBuildParameter

	PromptEphemeralParameters bool
	EphemeralParameters       []codersdk.WorkspaceBuildParameter

	PresetParameters      []codersdk.WorkspaceBuildParameter
	PromptRichParameters  bool
	RichParameters        []codersdk.WorkspaceBuildParameter
	RichParameterFile     string
	RichParameterDefaults []codersdk.WorkspaceBuildParameter

	UseParameterDefaults bool
}

// resolvePreset returns the preset matching the given presetName (if specified),
// or the default preset (if any).
// Returns ErrNoPresetFound if no matching or default preset is found.
func resolvePreset(presets []codersdk.Preset, presetName string) (*codersdk.Preset, error) {
	// If preset name is specified, find it
	if presetName != "" {
		for _, p := range presets {
			if p.Name == presetName {
				return &p, nil
			}
		}
		return nil, xerrors.Errorf("preset %q not found", presetName)
	}

	// No preset name specified, search for the default preset
	for _, p := range presets {
		if p.Default {
			return &p, nil
		}
	}

	// No preset found
	return nil, ErrNoPresetFound
}

const (
	// presetNoneOption is the selection menu label for creating a workspace
	// without a preset, matching the dashboard's "None" option.
	presetNoneOption = "None"
	// presetNoneFallbackOption replaces presetNoneOption when a preset's
	// label is also "None", so the menu never shows two identical entries.
	presetNoneFallbackOption = "None (no preset)"
)

// presetSelectOptions builds the preset selection menu entries. The first
// entry is the label for applying no preset, which is also returned as
// noneOption. presetMap maps every other entry to its preset.
func presetSelectOptions(presets []codersdk.Preset) (options []string, noneOption string, presetMap map[string]*codersdk.Preset) {
	presetMap = make(map[string]*codersdk.Preset, len(presets))
	presetOptions := make([]string, 0, len(presets))
	for _, preset := range presets {
		var option string
		if preset.Description == "" {
			option = preset.Name
		} else {
			option = fmt.Sprintf("%s: %s", preset.Name, preset.Description)
		}
		presetOptions = append(presetOptions, option)
		presetMap[option] = &preset
	}

	noneOption = presetNoneOption
	if _, ok := presetMap[noneOption]; ok {
		noneOption = presetNoneFallbackOption
	}
	return append([]string{noneOption}, presetOptions...), noneOption, presetMap
}

// promptPresetSelection shows a CLI selection menu of the presets defined in the template version,
// preceded by an option to apply no preset.
// Returns the selected preset, or nil if the user chose to apply no preset.
func promptPresetSelection(inv *serpent.Invocation, presets []codersdk.Preset) (*codersdk.Preset, error) {
	presetOptions, noneOption, presetMap := presetSelectOptions(presets)

	// Show selection UI
	_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a preset below:"))
	selected, err := cliui.Select(inv, cliui.SelectOptions{
		Options:    presetOptions,
		HideSearch: true,
	})
	if err != nil {
		return nil, xerrors.Errorf("failed to select preset: %w", err)
	}

	if selected == noneOption {
		return nil, nil //nolint:nilnil // A nil preset means no preset is applied.
	}
	return presetMap[selected], nil
}

// displayAppliedPreset shows the user which preset was applied and its parameters
func displayAppliedPreset(inv *serpent.Invocation, preset *codersdk.Preset, parameters []codersdk.WorkspaceBuildParameter) {
	label := fmt.Sprintf("Preset '%s'", preset.Name)
	if preset.Default {
		label += " (default)"
	}

	_, _ = fmt.Fprintf(inv.Stdout, "%s applied:\n", cliui.Bold(label))
	for _, param := range parameters {
		_, _ = fmt.Fprintf(inv.Stdout, "  %s: '%s'\n", cliui.Bold(param.Name), param.Value)
	}
}

// prepWorkspaceBuild will ensure a workspace build will succeed on the latest template version.
// Any missing params will be prompted to the user. It supports rich parameters.
func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.WorkspaceBuildParameter, error) {
	ctx := inv.Context()

	templateVersion, err := client.TemplateVersion(ctx, args.TemplateVersionID)
	if err != nil {
		return nil, xerrors.Errorf("get template version: %w", err)
	}

	dynamicParameters := true
	if templateVersion.TemplateID != nil {
		// TODO: This fetch is often redundant, as the caller often has the template already.
		template, err := client.Template(ctx, *templateVersion.TemplateID)
		if err != nil {
			return nil, xerrors.Errorf("get template: %w", err)
		}
		dynamicParameters = !template.UseClassicParameterFlow
	}

	parameterFile := map[string]string{}
	if args.RichParameterFile != "" {
		parameterFile, err = parseParameterMapFile(args.RichParameterFile)
		if err != nil {
			return nil, xerrors.Errorf("can't parse parameter map file: %w", err)
		}
	}

	resolver := new(ParameterResolver).
		WithLastBuildParameters(args.LastBuildParameters).
		WithSourceWorkspaceParameters(args.SourceWorkspaceParameters).
		WithPromptEphemeralParameters(args.PromptEphemeralParameters).
		WithEphemeralParameters(args.EphemeralParameters).
		WithPresetParameters(args.PresetParameters).
		WithPromptRichParameters(args.PromptRichParameters).
		WithRichParameters(args.RichParameters).
		WithRichParametersFile(parameterFile).
		WithRichParametersDefaults(args.RichParameterDefaults).
		WithUseParameterDefaults(args.UseParameterDefaults)

	var templateVersionParameters []codersdk.TemplateVersionParameter
	if !dynamicParameters {
		templateVersionParameters, err = client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID)
		if err != nil {
			return nil, xerrors.Errorf("get template version rich parameters: %w", err)
		}
	} else {
		var ownerID uuid.UUID
		{ // Putting in its own block to limit scope of owningMember, as it might be nil
			owningMember, err := client.OrganizationMember(ctx, templateVersion.OrganizationID.String(), args.Owner)
			if err != nil {
				// This is unfortunate, but if we are an org owner, then we can create workspaces
				// for users that are not part of the organization.
				owningUser, uerr := client.User(ctx, args.Owner)
				if uerr != nil {
					return nil, xerrors.Errorf("get owning member: %w", err)
				}
				ownerID = owningUser.ID
			} else {
				ownerID = owningMember.UserID
			}
		}

		initial := make(map[string]string)
		for _, v := range resolver.InitialValues() {
			initial[v.Name] = v.Value
		}

		eval, err := client.EvaluateTemplateVersion(ctx, templateVersion.ID, ownerID, initial)
		if err != nil {
			return nil, xerrors.Errorf("evaluate template version dynamic parameters: %w", err)
		}

		for _, param := range eval.Parameters {
			templateVersionParameters = append(templateVersionParameters, param.TemplateVersionParameter())
		}
	}

	buildParameters, err := resolver.Resolve(inv, args.Action, templateVersionParameters)
	if err != nil {
		return nil, err
	}

	err = cliui.ExternalAuth(ctx, inv.Stdout, cliui.ExternalAuthOptions{
		Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) {
			return client.TemplateVersionExternalAuth(ctx, templateVersion.ID)
		},
	})
	if err != nil {
		return nil, xerrors.Errorf("template version git auth: %w", err)
	}

	// Only perform dry-run for workspace creation and updates
	// Skip for start and restart to avoid unnecessary delays
	if args.Action == WorkspaceCreate || args.Action == WorkspaceUpdate {
		// Run a dry-run with the given parameters to check correctness
		dryRun, err := client.CreateTemplateVersionDryRun(inv.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
			WorkspaceName:       args.NewWorkspaceName,
			RichParameterValues: buildParameters,
		})
		if err != nil {
			return nil, xerrors.Errorf("begin workspace dry-run: %w", err)
		}

		matchedProvisioners, err := client.TemplateVersionDryRunMatchedProvisioners(inv.Context(), templateVersion.ID, dryRun.ID)
		if err != nil {
			return nil, xerrors.Errorf("get matched provisioners: %w", err)
		}
		cliutil.WarnMatchedProvisioners(inv.Stdout, &matchedProvisioners, dryRun)
		_, _ = fmt.Fprintln(inv.Stdout, "Planning workspace...")
		err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
			Fetch: func() (codersdk.ProvisionerJob, error) {
				return client.TemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
			},
			Cancel: func() error {
				return client.CancelTemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
			},
			Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
				return client.TemplateVersionDryRunLogsAfter(inv.Context(), templateVersion.ID, dryRun.ID, 0)
			},
			// Don't show log output for the dry-run unless there's an error.
			Silent: true,
		})
		if err != nil {
			// TODO (Dean): reprompt for parameter values if we deem it to
			// be a validation error
			return nil, xerrors.Errorf("dry-run workspace: %w", err)
		}

		resources, err := client.TemplateVersionDryRunResources(inv.Context(), templateVersion.ID, dryRun.ID)
		if err != nil {
			return nil, xerrors.Errorf("get workspace dry-run resources: %w", err)
		}

		// Keep the owner in suggested commands when creating a workspace for
		// someone else, so they resolve to the new workspace.
		previewName := args.NewWorkspaceName
		if args.Action == WorkspaceCreate && args.Owner != "" && args.Owner != codersdk.Me {
			previewName = args.Owner + "/" + args.NewWorkspaceName
		}
		err = cliui.WorkspaceResources(inv.Stdout, resources, cliui.WorkspaceResourcesOptions{
			WorkspaceName: previewName,
			// Since agents haven't connected yet, hiding this makes more sense.
			HideAgentState: true,
			Title:          "Workspace Preview",
		})
		if err != nil {
			return nil, xerrors.Errorf("get resources: %w", err)
		}
	}

	return buildParameters, nil
}