mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
- As part of merging support for Template RBAC and user groups a permission check on reading files was relaxed. With the addition of admin roles on individual templates, regular users are now able to push template versions if they have inherited the 'admin' role for a template. In order to do so they need to be able to create and read their own files. Since collisions on hash in the past were ignored, this means that a regular user who pushes a template version with a file hash that collides with an existing hash will not be able to read the file (since it belongs to another user). This commit fixes the underlying problem which was that the files table had a primary key on the 'hash' column. This was not a problem at the time because only template admins and other users with similar elevated roles were able to read all files regardless of ownership. To fix this a new column and primary key 'id' has been introduced to the files table. The unique constraint has been updated to be hash+created_by. Tables (provisioner_jobs) that referenced files.hash have been updated to reference files.id. Relevant API endpoints have also been updated.
341 lines
12 KiB
Go
341 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/briandowns/spinner"
|
|
"github.com/google/uuid"
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/cli/cliui"
|
|
"github.com/coder/coder/coderd/database"
|
|
"github.com/coder/coder/coderd/util/ptr"
|
|
"github.com/coder/coder/codersdk"
|
|
"github.com/coder/coder/provisionerd"
|
|
"github.com/coder/coder/provisionersdk"
|
|
)
|
|
|
|
func templateCreate() *cobra.Command {
|
|
var (
|
|
directory string
|
|
provisioner string
|
|
parameterFile string
|
|
maxTTL time.Duration
|
|
minAutostartInterval time.Duration
|
|
)
|
|
cmd := &cobra.Command{
|
|
Use: "create [name]",
|
|
Short: "Create a template from the current directory or as specified by flag",
|
|
Args: cobra.MaximumNArgs(1),
|
|
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 templateName string
|
|
if len(args) == 0 {
|
|
templateName = filepath.Base(directory)
|
|
} else {
|
|
templateName = args[0]
|
|
}
|
|
|
|
if utf8.RuneCountInString(templateName) > 31 {
|
|
return xerrors.Errorf("Template name must be less than 32 characters")
|
|
}
|
|
|
|
_, err = client.TemplateByName(cmd.Context(), organization.ID, templateName)
|
|
if err == nil {
|
|
return xerrors.Errorf("A template already exists named %q!", templateName)
|
|
}
|
|
|
|
// Confirm upload of the directory.
|
|
prettyDir := prettyDirectoryPath(directory)
|
|
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
|
Text: fmt.Sprintf("Create and upload %q?", prettyDir),
|
|
IsConfirm: true,
|
|
Default: cliui.ConfirmYes,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
|
|
spin.Writer = cmd.OutOrStdout()
|
|
spin.Suffix = cliui.Styles.Keyword.Render(" Uploading directory...")
|
|
spin.Start()
|
|
defer spin.Stop()
|
|
archive, err := provisionersdk.Tar(directory, provisionersdk.TemplateArchiveLimit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, archive)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
spin.Stop()
|
|
|
|
job, _, err := createValidTemplateVersion(cmd, createValidTemplateVersionArgs{
|
|
Client: client,
|
|
Organization: organization,
|
|
Provisioner: database.ProvisionerType(provisioner),
|
|
FileID: resp.ID,
|
|
ParameterFile: parameterFile,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
|
Text: "Confirm create?",
|
|
IsConfirm: true,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
createReq := codersdk.CreateTemplateRequest{
|
|
Name: templateName,
|
|
VersionID: job.ID,
|
|
MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()),
|
|
MinAutostartIntervalMillis: ptr.Ref(minAutostartInterval.Milliseconds()),
|
|
}
|
|
|
|
_, err = client.CreateTemplate(cmd.Context(), organization.ID, createReq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\n"+cliui.Styles.Wrap.Render(
|
|
"The "+cliui.Styles.Keyword.Render(templateName)+" template has been created at "+cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp))+"! "+
|
|
"Developers can provision a workspace with this template using:")+"\n")
|
|
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render(fmt.Sprintf("coder create --template=%q [workspace name]", templateName)))
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
|
|
|
return nil
|
|
},
|
|
}
|
|
currentDirectory, _ := os.Getwd()
|
|
cmd.Flags().StringVarP(&directory, "directory", "d", currentDirectory, "Specify the directory to create from")
|
|
cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend")
|
|
cmd.Flags().StringVarP(¶meterFile, "parameter-file", "", "", "Specify a file path with parameter values.")
|
|
cmd.Flags().DurationVarP(&maxTTL, "max-ttl", "", 24*time.Hour, "Specify a maximum TTL for workspaces created from this template.")
|
|
cmd.Flags().DurationVarP(&minAutostartInterval, "min-autostart-interval", "", time.Hour, "Specify a minimum autostart interval for workspaces created from this template.")
|
|
// This is for testing!
|
|
err := cmd.Flags().MarkHidden("test.provisioner")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
cliui.AllowSkipPrompt(cmd)
|
|
return cmd
|
|
}
|
|
|
|
type createValidTemplateVersionArgs struct {
|
|
Name string
|
|
Client *codersdk.Client
|
|
Organization codersdk.Organization
|
|
Provisioner database.ProvisionerType
|
|
FileID uuid.UUID
|
|
ParameterFile string
|
|
// Template is only required if updating a template's active version.
|
|
Template *codersdk.Template
|
|
// ReuseParameters will attempt to reuse params from the Template field
|
|
// before prompting the user. Set to false to always prompt for param
|
|
// values.
|
|
ReuseParameters bool
|
|
}
|
|
|
|
func createValidTemplateVersion(cmd *cobra.Command, args createValidTemplateVersionArgs, parameters ...codersdk.CreateParameterRequest) (*codersdk.TemplateVersion, []codersdk.CreateParameterRequest, error) {
|
|
before := time.Now()
|
|
client := args.Client
|
|
|
|
req := codersdk.CreateTemplateVersionRequest{
|
|
Name: args.Name,
|
|
StorageMethod: codersdk.ProvisionerStorageMethodFile,
|
|
FileID: args.FileID,
|
|
Provisioner: codersdk.ProvisionerType(args.Provisioner),
|
|
ParameterValues: parameters,
|
|
}
|
|
if args.Template != nil {
|
|
req.TemplateID = args.Template.ID
|
|
}
|
|
version, err := client.CreateTemplateVersion(cmd.Context(), args.Organization.ID, req)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
err = cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{
|
|
Fetch: func() (codersdk.ProvisionerJob, error) {
|
|
version, err := client.TemplateVersion(cmd.Context(), version.ID)
|
|
return version.Job, err
|
|
},
|
|
Cancel: func() error {
|
|
return client.CancelTemplateVersion(cmd.Context(), version.ID)
|
|
},
|
|
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
|
|
return client.TemplateVersionLogsAfter(cmd.Context(), version.ID, before)
|
|
},
|
|
})
|
|
if err != nil {
|
|
if !provisionerd.IsMissingParameterError(err.Error()) {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
version, err = client.TemplateVersion(cmd.Context(), version.ID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
parameterSchemas, err := client.TemplateVersionSchema(cmd.Context(), version.ID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
parameterValues, err := client.TemplateVersionParameters(cmd.Context(), version.ID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// lastParameterValues are pulled from the current active template version if
|
|
// templateID is provided. This allows pulling params from the last
|
|
// version instead of prompting if we are updating template versions.
|
|
lastParameterValues := make(map[string]codersdk.Parameter)
|
|
if args.ReuseParameters && args.Template != nil {
|
|
activeVersion, err := client.TemplateVersion(cmd.Context(), args.Template.ActiveVersionID)
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("Fetch current active template version: %w", err)
|
|
}
|
|
|
|
// We don't want to compute the params, we only want to copy from this scope
|
|
values, err := client.Parameters(cmd.Context(), codersdk.ParameterImportJob, activeVersion.Job.ID)
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("Fetch previous version parameters: %w", err)
|
|
}
|
|
for _, value := range values {
|
|
lastParameterValues[value.Name] = value
|
|
}
|
|
}
|
|
|
|
if provisionerd.IsMissingParameterError(version.Job.Error) {
|
|
valuesBySchemaID := map[string]codersdk.ComputedParameter{}
|
|
for _, parameterValue := range parameterValues {
|
|
valuesBySchemaID[parameterValue.SchemaID.String()] = parameterValue
|
|
}
|
|
|
|
// parameterMapFromFile can be nil if parameter file is not specified
|
|
var parameterMapFromFile map[string]string
|
|
if args.ParameterFile != "" {
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n")
|
|
parameterMapFromFile, err = createParameterMapFromFile(args.ParameterFile)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
// pulled params come from the last template version
|
|
pulled := make([]string, 0)
|
|
missingSchemas := make([]codersdk.ParameterSchema, 0)
|
|
for _, parameterSchema := range parameterSchemas {
|
|
_, ok := valuesBySchemaID[parameterSchema.ID.String()]
|
|
if ok {
|
|
continue
|
|
}
|
|
|
|
// The file values are handled below. So don't handle them here,
|
|
// just check if a value is present in the file.
|
|
_, fileOk := parameterMapFromFile[parameterSchema.Name]
|
|
if inherit, ok := lastParameterValues[parameterSchema.Name]; ok && !fileOk {
|
|
// If the value is not in the param file, and can be pulled from the last template version,
|
|
// then don't mark it as missing.
|
|
parameters = append(parameters, codersdk.CreateParameterRequest{
|
|
CloneID: inherit.ID,
|
|
})
|
|
pulled = append(pulled, fmt.Sprintf("%q", parameterSchema.Name))
|
|
continue
|
|
}
|
|
|
|
missingSchemas = append(missingSchemas, parameterSchema)
|
|
}
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has required variables! They are scoped to the template, and not viewable after being set."))
|
|
if len(pulled) > 0 {
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(fmt.Sprintf("The following parameter values are being pulled from the latest template version: %s.", strings.Join(pulled, ", "))))
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Use \"--always-prompt\" flag to change the values."))
|
|
}
|
|
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\r\n")
|
|
|
|
for _, parameterSchema := range missingSchemas {
|
|
parameterValue, err := getParameterValueFromMapOrInput(cmd, parameterMapFromFile, parameterSchema)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
parameters = append(parameters, codersdk.CreateParameterRequest{
|
|
Name: parameterSchema.Name,
|
|
SourceValue: parameterValue,
|
|
SourceScheme: codersdk.ParameterSourceSchemeData,
|
|
DestinationScheme: parameterSchema.DefaultDestinationScheme,
|
|
})
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
|
}
|
|
|
|
// This recursion is only 1 level deep in practice.
|
|
// The first pass populates the missing parameters, so it does not enter this `if` block again.
|
|
return createValidTemplateVersion(cmd, args, parameters...)
|
|
}
|
|
|
|
if version.Job.Status != codersdk.ProvisionerJobSucceeded {
|
|
return nil, nil, xerrors.New(version.Job.Error)
|
|
}
|
|
|
|
resources, err := client.TemplateVersionResources(cmd.Context(), version.ID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Only display the resources on the start transition, to avoid listing them more than once.
|
|
var startResources []codersdk.WorkspaceResource
|
|
for _, r := range resources {
|
|
if r.Transition == codersdk.WorkspaceTransitionStart {
|
|
startResources = append(startResources, r)
|
|
}
|
|
}
|
|
err = cliui.WorkspaceResources(cmd.OutOrStdout(), startResources, cliui.WorkspaceResourcesOptions{
|
|
HideAgentState: true,
|
|
HideAccess: true,
|
|
Title: "Template Preview",
|
|
})
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("preview template resources: %w", err)
|
|
}
|
|
|
|
return &version, parameters, nil
|
|
}
|
|
|
|
// prettyDirectoryPath returns a prettified path when inside the users
|
|
// home directory. Falls back to dir if the users home directory cannot
|
|
// discerned. This function calls filepath.Clean on the result.
|
|
func prettyDirectoryPath(dir string) string {
|
|
dir = filepath.Clean(dir)
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return dir
|
|
}
|
|
pretty := dir
|
|
if strings.HasPrefix(pretty, homeDir) {
|
|
pretty = strings.TrimPrefix(pretty, homeDir)
|
|
pretty = "~" + pretty
|
|
}
|
|
return pretty
|
|
}
|