mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
This PR adds fields to templates that constrain values for workspaces derived from that template. - Autostop: Adds a field max_ttl on the template which limits the maximum value of ttl on all workspaces derived from that template. Defaulting to 168 hours, enforced on edits to workspace metadata. New workspaces will default to the templates's `max_ttl` if not specified. - Autostart: Adds a field min_autostart_duration which limits the minimum duration between successive autostarts of a template, measured from a single reference time. Defaulting to 1 hour, enforced on edits to workspace metadata.
292 lines
9.8 KiB
Go
292 lines
9.8 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/exp/slices"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/cli/cliflag"
|
|
"github.com/coder/coder/cli/cliui"
|
|
"github.com/coder/coder/coderd/autobuild/schedule"
|
|
"github.com/coder/coder/coderd/util/ptr"
|
|
"github.com/coder/coder/codersdk"
|
|
)
|
|
|
|
func create() *cobra.Command {
|
|
var (
|
|
autostartMinute string
|
|
autostartHour string
|
|
autostartDow string
|
|
parameterFile string
|
|
templateName string
|
|
ttl time.Duration
|
|
tzName string
|
|
workspaceName string
|
|
)
|
|
cmd := &cobra.Command{
|
|
Annotations: workspaceCommand,
|
|
Use: "create [name]",
|
|
Short: "Create a workspace from a template",
|
|
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
|
|
}
|
|
|
|
if len(args) >= 1 {
|
|
workspaceName = args[0]
|
|
}
|
|
|
|
if workspaceName == "" {
|
|
workspaceName, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
|
Text: "Specify a name for your workspace:",
|
|
Validate: func(workspaceName string) error {
|
|
_, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName)
|
|
if err == nil {
|
|
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
|
|
}
|
|
return nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
_, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName)
|
|
if err == nil {
|
|
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
|
|
}
|
|
|
|
var template codersdk.Template
|
|
if templateName == "" {
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Select a template below to preview the provisioned infrastructure:"))
|
|
|
|
templates, err := client.TemplatesByOrganization(cmd.Context(), organization.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
slices.SortFunc(templates, func(a, b codersdk.Template) bool {
|
|
return a.WorkspaceOwnerCount > b.WorkspaceOwnerCount
|
|
})
|
|
|
|
templateNames := make([]string, 0, len(templates))
|
|
templateByName := make(map[string]codersdk.Template, len(templates))
|
|
|
|
for _, template := range templates {
|
|
templateName := template.Name
|
|
|
|
if template.WorkspaceOwnerCount > 0 {
|
|
developerText := "developer"
|
|
if template.WorkspaceOwnerCount != 1 {
|
|
developerText = "developers"
|
|
}
|
|
|
|
templateName += cliui.Styles.Placeholder.Render(fmt.Sprintf(" (used by %d %s)", template.WorkspaceOwnerCount, developerText))
|
|
}
|
|
|
|
templateNames = append(templateNames, templateName)
|
|
templateByName[templateName] = template
|
|
}
|
|
|
|
// Move the cursor up a single line for nicer display!
|
|
option, err := cliui.Select(cmd, cliui.SelectOptions{
|
|
Options: templateNames,
|
|
HideSearch: true,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
template = templateByName[option]
|
|
} else {
|
|
template, err = client.TemplateByName(cmd.Context(), organization.ID, templateName)
|
|
if err != nil {
|
|
return xerrors.Errorf("get template by name: %w", err)
|
|
}
|
|
}
|
|
|
|
schedSpec, err := validSchedule(
|
|
autostartMinute,
|
|
autostartHour,
|
|
autostartDow,
|
|
tzName,
|
|
time.Duration(template.MinAutostartIntervalMillis)*time.Millisecond,
|
|
)
|
|
if err != nil {
|
|
return xerrors.Errorf("Invalid autostart schedule: %w", err)
|
|
}
|
|
if ttl < time.Minute {
|
|
return xerrors.Errorf("TTL must be at least 1 minute")
|
|
}
|
|
if ttlMax := time.Duration(template.MaxTTLMillis) * time.Millisecond; ttl > ttlMax {
|
|
return xerrors.Errorf("TTL must be below template maximum %s", ttlMax)
|
|
}
|
|
|
|
templateVersion, err := client.TemplateVersion(cmd.Context(), template.ActiveVersionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
parameterSchemas, err := client.TemplateVersionSchema(cmd.Context(), templateVersion.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// parameterMapFromFile can be nil if parameter file is not specified
|
|
var parameterMapFromFile map[string]string
|
|
if parameterFile != "" {
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n")
|
|
parameterMapFromFile, err = createParameterMapFromFile(parameterFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
disclaimerPrinted := false
|
|
parameters := make([]codersdk.CreateParameterRequest, 0)
|
|
for _, parameterSchema := range parameterSchemas {
|
|
if !parameterSchema.AllowOverrideSource {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
parameterValue, err := getParameterValueFromMapOrInput(cmd, parameterMapFromFile, parameterSchema)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
parameters = append(parameters, codersdk.CreateParameterRequest{
|
|
Name: parameterSchema.Name,
|
|
SourceValue: parameterValue,
|
|
SourceScheme: codersdk.ParameterSourceSchemeData,
|
|
DestinationScheme: parameterSchema.DefaultDestinationScheme,
|
|
})
|
|
}
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
|
|
|
// Run a dry-run with the given parameters to check correctness
|
|
after := time.Now()
|
|
dryRun, err := client.CreateTemplateVersionDryRun(cmd.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
|
WorkspaceName: workspaceName,
|
|
ParameterValues: parameters,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("begin workspace dry-run: %w", err)
|
|
}
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Planning workspace...")
|
|
err = cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{
|
|
Fetch: func() (codersdk.ProvisionerJob, error) {
|
|
return client.TemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
|
|
},
|
|
Cancel: func() error {
|
|
return client.CancelTemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
|
|
},
|
|
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
|
|
return client.TemplateVersionDryRunLogsAfter(cmd.Context(), templateVersion.ID, dryRun.ID, after)
|
|
},
|
|
// 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 xerrors.Errorf("dry-run workspace: %w", err)
|
|
}
|
|
|
|
resources, err := client.TemplateVersionDryRunResources(cmd.Context(), templateVersion.ID, dryRun.ID)
|
|
if err != nil {
|
|
return xerrors.Errorf("get workspace dry-run resources: %w", err)
|
|
}
|
|
|
|
err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
|
|
WorkspaceName: workspaceName,
|
|
// Since agent's haven't connected yet, hiding this makes more sense.
|
|
HideAgentState: true,
|
|
Title: "Workspace Preview",
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
|
Text: "Confirm create?",
|
|
IsConfirm: true,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.CreateWorkspaceRequest{
|
|
TemplateID: template.ID,
|
|
Name: workspaceName,
|
|
AutostartSchedule: schedSpec,
|
|
TTLMillis: ptr.Ref(ttl.Milliseconds()),
|
|
ParameterValues: parameters,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, workspace.LatestBuild.ID, after)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resources, err = client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
|
|
WorkspaceName: workspaceName,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "The %s workspace has been created!\n", cliui.Styles.Keyword.Render(workspace.Name))
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cliui.AllowSkipPrompt(cmd)
|
|
cliflag.StringVarP(cmd.Flags(), &templateName, "template", "t", "CODER_TEMPLATE_NAME", "", "Specify a template name.")
|
|
cliflag.StringVarP(cmd.Flags(), ¶meterFile, "parameter-file", "", "CODER_PARAMETER_FILE", "", "Specify a file path with parameter values.")
|
|
cliflag.StringVarP(cmd.Flags(), &autostartMinute, "autostart-minute", "", "CODER_WORKSPACE_AUTOSTART_MINUTE", "0", "Specify the minute(s) at which the workspace should autostart (e.g. 0).")
|
|
cliflag.StringVarP(cmd.Flags(), &autostartHour, "autostart-hour", "", "CODER_WORKSPACE_AUTOSTART_HOUR", "9", "Specify the hour(s) at which the workspace should autostart (e.g. 9).")
|
|
cliflag.StringVarP(cmd.Flags(), &autostartDow, "autostart-day-of-week", "", "CODER_WORKSPACE_AUTOSTART_DOW", "MON-FRI", "Specify the days(s) on which the workspace should autostart (e.g. MON,TUE,WED,THU,FRI)")
|
|
cliflag.StringVarP(cmd.Flags(), &tzName, "tz", "", "TZ", "UTC", "Specify your timezone location for workspace autostart (e.g. US/Central).")
|
|
cliflag.DurationVarP(cmd.Flags(), &ttl, "ttl", "", "CODER_WORKSPACE_TTL", 8*time.Hour, "Specify a time-to-live (TTL) for the workspace (e.g. 8h).")
|
|
return cmd
|
|
}
|
|
|
|
func validSchedule(minute, hour, dow, tzName string, min time.Duration) (*string, error) {
|
|
_, err := time.LoadLocation(tzName)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("Invalid workspace autostart timezone: %w", err)
|
|
}
|
|
|
|
schedSpec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", tzName, minute, hour, dow)
|
|
|
|
sched, err := schedule.Weekly(schedSpec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if schedMin := sched.Min(); schedMin < min {
|
|
return nil, xerrors.Errorf("minimum autostart interval %s is above template constraint %s", schedMin, min)
|
|
}
|
|
|
|
return &schedSpec, nil
|
|
}
|