2022-02-10 14:33:27 +00:00
package cli
2022-03-22 19:17:50 +00:00
import (
2023-02-04 20:07:09 +00:00
"bufio"
2024-01-05 21:04:14 +00:00
"errors"
2022-03-22 19:17:50 +00:00
"fmt"
2023-01-16 20:32:11 +00:00
"io"
2024-01-05 21:04:14 +00:00
"net/http"
2023-12-15 13:55:24 +00:00
"os"
2022-07-01 16:49:29 +00:00
"path/filepath"
2023-07-11 10:11:08 +00:00
"strings"
2022-03-22 19:17:50 +00:00
"time"
2024-01-05 21:04:14 +00:00
"unicode/utf8"
2022-03-22 19:17:50 +00:00
2022-05-12 11:54:58 +00:00
"github.com/briandowns/spinner"
2024-01-05 21:04:14 +00:00
"github.com/google/uuid"
2022-03-22 19:17:50 +00:00
"golang.org/x/xerrors"
2023-08-18 18:55:43 +00:00
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisionersdk"
2024-01-05 21:04:14 +00:00
"github.com/coder/pretty"
2024-03-15 16:24:38 +00:00
"github.com/coder/serpent"
2022-03-22 19:17:50 +00:00
)
2022-02-10 14:33:27 +00:00
2024-03-17 14:45:26 +00:00
func ( r * RootCmd ) templatePush ( ) * serpent . Command {
2022-05-12 11:54:58 +00:00
var (
2023-12-15 13:55:24 +00:00
versionName string
provisioner string
workdir string
variablesFile string
commandLineVariables [ ] string
alwaysPrompt bool
provisionerTags [ ] string
uploadFlags templateUploadFlags
activate bool
2022-05-12 11:54:58 +00:00
)
2023-03-23 22:42:20 +00:00
client := new ( codersdk . Client )
2024-03-17 14:45:26 +00:00
cmd := & serpent . Command {
2022-07-29 19:21:48 +00:00
Use : "push [template]" ,
2024-01-05 21:04:14 +00:00
Short : "Create or update a template from the current directory or as specified by flag" ,
2024-03-15 16:24:38 +00:00
Middleware : serpent . Chain (
serpent . RequireRangeArgs ( 0 , 1 ) ,
2023-03-23 22:42:20 +00:00
r . InitClient ( client ) ,
) ,
2024-03-15 16:24:38 +00:00
Handler : func ( inv * serpent . Invocation ) error {
2023-04-17 14:58:25 +00:00
uploadFlags . setWorkdir ( workdir )
2024-02-26 16:03:49 +00:00
organization , err := CurrentOrganization ( r , inv , client )
2022-03-22 19:17:50 +00:00
if err != nil {
return err
}
2022-07-01 16:49:29 +00:00
2023-03-23 22:42:20 +00:00
name , err := uploadFlags . templateName ( inv . Args )
2022-03-22 19:17:50 +00:00
if err != nil {
return err
}
2024-01-30 10:47:10 +00:00
if utf8 . RuneCountInString ( name ) > 32 {
return xerrors . Errorf ( "Template name must be no more than 32 characters" )
2024-01-05 21:04:14 +00:00
}
2023-07-13 10:58:34 +00:00
var createTemplate bool
2023-03-23 22:42:20 +00:00
template , err := client . TemplateByName ( inv . Context ( ) , organization . ID , name )
2022-03-22 19:17:50 +00:00
if err != nil {
2024-01-05 21:04:14 +00:00
var apiError * codersdk . Error
if errors . As ( err , & apiError ) && apiError . StatusCode ( ) != http . StatusNotFound {
2023-07-13 10:58:34 +00:00
return err
}
2024-01-05 21:04:14 +00:00
// Template doesn't exist, create it.
2023-07-13 10:58:34 +00:00
createTemplate = true
2022-03-22 19:17:50 +00:00
}
2022-05-12 11:54:58 +00:00
2023-06-20 15:02:44 +00:00
err = uploadFlags . checkForLockfile ( inv )
if err != nil {
return xerrors . Errorf ( "check for lockfile: %w" , err )
}
2023-07-11 10:11:08 +00:00
message := uploadFlags . templateMessage ( inv )
2024-01-12 14:08:23 +00:00
var varsFiles [ ] string
if ! uploadFlags . stdin ( ) {
varsFiles , err = DiscoverVarsFiles ( uploadFlags . directory )
if err != nil {
return err
}
if len ( varsFiles ) > 0 {
_ , _ = fmt . Fprintln ( inv . Stdout , "Auto-discovered Terraform tfvars files. Make sure to review and clean up any unused files." )
}
}
2023-03-23 22:42:20 +00:00
resp , err := uploadFlags . upload ( inv , client )
2022-03-22 19:17:50 +00:00
if err != nil {
return err
}
2022-11-16 22:34:06 +00:00
tags , err := ParseProvisionerTags ( provisionerTags )
if err != nil {
return err
}
2023-12-15 13:55:24 +00:00
userVariableValues , err := ParseUserVariableValues (
2024-01-12 14:08:23 +00:00
varsFiles ,
2023-12-15 13:55:24 +00:00
variablesFile ,
commandLineVariables )
if err != nil {
return err
}
2023-07-13 10:58:34 +00:00
args := createValidTemplateVersionArgs {
2023-12-15 13:55:24 +00:00
Message : message ,
Client : client ,
Organization : organization ,
Provisioner : codersdk . ProvisionerType ( provisioner ) ,
FileID : resp . ID ,
ProvisionerTags : tags ,
UserVariableValues : userVariableValues ,
2023-07-13 10:58:34 +00:00
}
if ! createTemplate {
args . Name = versionName
args . Template = & template
args . ReuseParameters = ! alwaysPrompt
}
job , err := createValidTemplateVersion ( inv , args )
2022-03-22 19:17:50 +00:00
if err != nil {
return err
}
2022-06-17 17:22:28 +00:00
if job . Job . Status != codersdk . ProvisionerJobSucceeded {
return xerrors . Errorf ( "job failed: %s" , job . Job . Status )
2022-03-22 19:17:50 +00:00
}
2023-07-13 10:58:34 +00:00
if createTemplate {
_ , err = client . CreateTemplate ( inv . Context ( ) , organization . ID , codersdk . CreateTemplateRequest {
Name : name ,
VersionID : job . ID ,
} )
if err != nil {
return err
}
2023-09-07 21:28:22 +00:00
_ , _ = fmt . Fprintln (
inv . Stdout , "\n" + cliui . Wrap (
"The " + cliui . Keyword ( name ) + " template has been created at " + cliui . Timestamp ( time . Now ( ) ) + "! " +
"Developers can provision a workspace with this template using:" ) + "\n" )
2023-07-13 10:58:34 +00:00
} else if activate {
2023-06-03 22:39:00 +00:00
err = client . UpdateActiveTemplateVersion ( inv . Context ( ) , template . ID , codersdk . UpdateActiveTemplateVersion {
ID : job . ID ,
} )
if err != nil {
return err
}
2022-03-22 19:17:50 +00:00
}
2022-06-17 17:22:28 +00:00
2023-09-07 21:28:22 +00:00
_ , _ = fmt . Fprintf ( inv . Stdout , "Updated version at %s!\n" , pretty . Sprint ( cliui . DefaultStyles . DateTimeStamp , time . Now ( ) . Format ( time . Stamp ) ) )
2022-02-10 14:33:27 +00:00
return nil
} ,
}
2022-05-12 11:54:58 +00:00
2024-03-15 16:24:38 +00:00
cmd . Options = serpent . OptionSet {
2023-03-23 22:42:20 +00:00
{
2023-04-17 14:58:25 +00:00
Flag : "test.provisioner" ,
Description : "Customize the provisioner backend." ,
Default : "terraform" ,
2024-03-15 16:24:38 +00:00
Value : serpent . StringOf ( & provisioner ) ,
2023-04-17 14:58:25 +00:00
// This is for testing!
Hidden : true ,
} ,
{
Flag : "test.workdir" ,
Description : "Customize the working directory." ,
Default : "" ,
2024-03-15 16:24:38 +00:00
Value : serpent . StringOf ( & workdir ) ,
2023-03-23 22:42:20 +00:00
// This is for testing!
Hidden : true ,
} ,
{
Flag : "variables-file" ,
Description : "Specify a file path with values for Terraform-managed variables." ,
2024-03-15 16:24:38 +00:00
Value : serpent . StringOf ( & variablesFile ) ,
2023-03-23 22:42:20 +00:00
} ,
{
Flag : "variable" ,
Description : "Specify a set of values for Terraform-managed variables." ,
2024-03-15 16:24:38 +00:00
Value : serpent . StringArrayOf ( & commandLineVariables ) ,
2023-07-25 14:36:02 +00:00
} ,
{
Flag : "var" ,
Description : "Alias of --variable." ,
2024-03-15 16:24:38 +00:00
Value : serpent . StringArrayOf ( & commandLineVariables ) ,
2023-03-23 22:42:20 +00:00
} ,
{
Flag : "provisioner-tag" ,
Description : "Specify a set of tags to target provisioner daemons." ,
2024-03-15 16:24:38 +00:00
Value : serpent . StringArrayOf ( & provisionerTags ) ,
2023-03-23 22:42:20 +00:00
} ,
{
Flag : "name" ,
Description : "Specify a name for the new template version. It will be automatically generated if not provided." ,
2024-03-15 16:24:38 +00:00
Value : serpent . StringOf ( & versionName ) ,
2023-03-23 22:42:20 +00:00
} ,
{
Flag : "always-prompt" ,
Description : "Always prompt all parameters. Does not pull parameter values from active template version." ,
2024-03-15 16:24:38 +00:00
Value : serpent . BoolOf ( & alwaysPrompt ) ,
2023-03-23 22:42:20 +00:00
} ,
2023-06-03 22:39:00 +00:00
{
Flag : "activate" ,
Description : "Whether the new template will be marked active." ,
Default : "true" ,
2024-03-15 16:24:38 +00:00
Value : serpent . BoolOf ( & activate ) ,
2023-06-03 22:39:00 +00:00
} ,
2023-03-23 22:42:20 +00:00
cliui . SkipPromptOption ( ) ,
2022-05-12 11:54:58 +00:00
}
2023-06-20 15:02:44 +00:00
cmd . Options = append ( cmd . Options , uploadFlags . options ( ) ... )
2022-05-12 11:54:58 +00:00
return cmd
2022-02-10 14:33:27 +00:00
}
2023-12-15 13:55:24 +00:00
2024-01-05 21:04:14 +00:00
type templateUploadFlags struct {
directory string
ignoreLockfile bool
message string
}
2024-03-15 16:24:38 +00:00
func ( pf * templateUploadFlags ) options ( ) [ ] serpent . Option {
return [ ] serpent . Option { {
2024-01-05 21:04:14 +00:00
Flag : "directory" ,
FlagShorthand : "d" ,
Description : "Specify the directory to create from, use '-' to read tar from stdin." ,
Default : "." ,
2024-03-15 16:24:38 +00:00
Value : serpent . StringOf ( & pf . directory ) ,
2024-01-05 21:04:14 +00:00
} , {
Flag : "ignore-lockfile" ,
Description : "Ignore warnings about not having a .terraform.lock.hcl file present in the template." ,
Default : "false" ,
2024-03-15 16:24:38 +00:00
Value : serpent . BoolOf ( & pf . ignoreLockfile ) ,
2024-01-05 21:04:14 +00:00
} , {
Flag : "message" ,
FlagShorthand : "m" ,
Description : "Specify a message describing the changes in this version of the template. Messages longer than 72 characters will be displayed as truncated." ,
2024-03-15 16:24:38 +00:00
Value : serpent . StringOf ( & pf . message ) ,
2024-01-05 21:04:14 +00:00
} }
}
func ( pf * templateUploadFlags ) setWorkdir ( wd string ) {
if wd == "" {
return
}
if pf . directory == "" || pf . directory == "." {
pf . directory = wd
} else if ! filepath . IsAbs ( pf . directory ) {
pf . directory = filepath . Join ( wd , pf . directory )
}
}
func ( pf * templateUploadFlags ) stdin ( ) bool {
return pf . directory == "-"
}
2024-03-15 16:24:38 +00:00
func ( pf * templateUploadFlags ) upload ( inv * serpent . Invocation , client * codersdk . Client ) ( * codersdk . UploadResponse , error ) {
2024-01-05 21:04:14 +00:00
var content io . Reader
if pf . stdin ( ) {
content = inv . Stdin
} else {
prettyDir := prettyDirectoryPath ( pf . directory )
_ , err := cliui . Prompt ( inv , cliui . PromptOptions {
Text : fmt . Sprintf ( "Upload %q?" , prettyDir ) ,
IsConfirm : true ,
Default : cliui . ConfirmYes ,
} )
if err != nil {
return nil , err
}
pipeReader , pipeWriter := io . Pipe ( )
go func ( ) {
err := provisionersdk . Tar ( pipeWriter , inv . Logger , pf . directory , provisionersdk . TemplateArchiveLimit )
_ = pipeWriter . CloseWithError ( err )
} ( )
defer pipeReader . Close ( )
content = pipeReader
}
spin := spinner . New ( spinner . CharSets [ 5 ] , 100 * time . Millisecond )
spin . Writer = inv . Stdout
spin . Suffix = pretty . Sprint ( cliui . DefaultStyles . Keyword , " Uploading directory..." )
spin . Start ( )
defer spin . Stop ( )
resp , err := client . Upload ( inv . Context ( ) , codersdk . ContentTypeTar , bufio . NewReader ( content ) )
if err != nil {
return nil , xerrors . Errorf ( "upload: %w" , err )
}
return & resp , nil
}
2024-03-15 16:24:38 +00:00
func ( pf * templateUploadFlags ) checkForLockfile ( inv * serpent . Invocation ) error {
2024-01-05 21:04:14 +00:00
if pf . stdin ( ) || pf . ignoreLockfile {
// Just assume there's a lockfile if reading from stdin.
return nil
}
hasLockfile , err := provisionersdk . DirHasLockfile ( pf . directory )
if err != nil {
return xerrors . Errorf ( "dir has lockfile: %w" , err )
}
if ! hasLockfile {
cliui . Warn ( inv . Stdout , "No .terraform.lock.hcl file found" ,
"When provisioning, Coder will be unable to cache providers without a lockfile and must download them from the internet each time." ,
"Create one by running " + pretty . Sprint ( cliui . DefaultStyles . Code , "terraform init" ) + " in your template directory." ,
)
}
return nil
}
2024-03-15 16:24:38 +00:00
func ( pf * templateUploadFlags ) templateMessage ( inv * serpent . Invocation ) string {
2024-01-05 21:04:14 +00:00
title := strings . SplitN ( pf . message , "\n" , 2 ) [ 0 ]
if len ( title ) > 72 {
cliui . Warn ( inv . Stdout , "Template message is longer than 72 characters, it will be displayed as truncated." )
}
if title != pf . message {
cliui . Warn ( inv . Stdout , "Template message contains newlines, only the first line will be displayed." )
}
if pf . message != "" {
return pf . message
}
return "Uploaded from the CLI"
}
func ( pf * templateUploadFlags ) templateName ( args [ ] string ) ( string , error ) {
if pf . stdin ( ) {
// Can't infer name from directory if none provided.
if len ( args ) == 0 {
return "" , xerrors . New ( "template name argument must be provided" )
}
return args [ 0 ] , nil
}
if len ( args ) > 0 {
return args [ 0 ] , nil
}
// Have to take absPath to resolve "." and "..".
absPath , err := filepath . Abs ( pf . directory )
if err != nil {
return "" , err
}
// If no name is provided, use the directory name.
return filepath . Base ( absPath ) , nil
}
type createValidTemplateVersionArgs struct {
Name string
Message string
Client * codersdk . Client
Organization codersdk . Organization
Provisioner codersdk . ProvisionerType
FileID uuid . UUID
// 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
ProvisionerTags map [ string ] string
UserVariableValues [ ] codersdk . VariableValue
}
2024-03-15 16:24:38 +00:00
func createValidTemplateVersion ( inv * serpent . Invocation , args createValidTemplateVersionArgs ) ( * codersdk . TemplateVersion , error ) {
2024-01-05 21:04:14 +00:00
client := args . Client
req := codersdk . CreateTemplateVersionRequest {
Name : args . Name ,
Message : args . Message ,
StorageMethod : codersdk . ProvisionerStorageMethodFile ,
FileID : args . FileID ,
Provisioner : args . Provisioner ,
ProvisionerTags : args . ProvisionerTags ,
UserVariableValues : args . UserVariableValues ,
}
if args . Template != nil {
req . TemplateID = args . Template . ID
}
version , err := client . CreateTemplateVersion ( inv . Context ( ) , args . Organization . ID , req )
if err != nil {
return nil , err
}
err = cliui . ProvisionerJob ( inv . Context ( ) , inv . Stdout , cliui . ProvisionerJobOptions {
Fetch : func ( ) ( codersdk . ProvisionerJob , error ) {
version , err := client . TemplateVersion ( inv . Context ( ) , version . ID )
return version . Job , err
} ,
Cancel : func ( ) error {
return client . CancelTemplateVersion ( inv . Context ( ) , version . ID )
} ,
Logs : func ( ) ( <- chan codersdk . ProvisionerJobLog , io . Closer , error ) {
return client . TemplateVersionLogsAfter ( inv . Context ( ) , version . ID , 0 )
} ,
} )
if err != nil {
var jobErr * cliui . ProvisionerJobError
if errors . As ( err , & jobErr ) && ! codersdk . JobIsMissingParameterErrorCode ( jobErr . Code ) {
return nil , err
}
if err != nil {
return nil , err
}
}
version , err = client . TemplateVersion ( inv . Context ( ) , version . ID )
if err != nil {
return nil , err
}
if version . Job . Status != codersdk . ProvisionerJobSucceeded {
return nil , xerrors . New ( version . Job . Error )
}
resources , err := client . TemplateVersionResources ( inv . Context ( ) , version . ID )
if err != nil {
return 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 ( inv . Stdout , startResources , cliui . WorkspaceResourcesOptions {
HideAgentState : true ,
HideAccess : true ,
Title : "Template Preview" ,
} )
if err != nil {
return nil , xerrors . Errorf ( "preview template resources: %w" , err )
}
return & version , nil
}
func ParseProvisionerTags ( rawTags [ ] string ) ( map [ string ] string , error ) {
tags := map [ string ] string { }
for _ , rawTag := range rawTags {
parts := strings . SplitN ( rawTag , "=" , 2 )
if len ( parts ) < 2 {
return nil , xerrors . Errorf ( "invalid tag format for %q. must be key=value" , rawTag )
}
tags [ parts [ 0 ] ] = parts [ 1 ]
}
return tags , nil
}
2023-12-15 13:55:24 +00:00
// 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
}
prettyDir := dir
if strings . HasPrefix ( prettyDir , homeDir ) {
prettyDir = strings . TrimPrefix ( prettyDir , homeDir )
prettyDir = "~" + prettyDir
}
return prettyDir
}