github.com/kevinklinger/open_terraform@v1.3.6/noninternal/builtin/provisioners/local-exec/resource_provisioner.go (about) 1 package localexec 2 3 import ( 4 "context" 5 "fmt" 6 "io" 7 "os" 8 "os/exec" 9 "runtime" 10 11 "github.com/armon/circbuf" 12 "github.com/kevinklinger/open_terraform/noninternal/configs/configschema" 13 "github.com/kevinklinger/open_terraform/noninternal/provisioners" 14 "github.com/kevinklinger/open_terraform/noninternal/tfdiags" 15 "github.com/mitchellh/go-linereader" 16 "github.com/zclconf/go-cty/cty" 17 ) 18 19 const ( 20 // maxBufSize limits how much output we collect from a local 21 // invocation. This is to prevent TF memory usage from growing 22 // to an enormous amount due to a faulty process. 23 maxBufSize = 8 * 1024 24 ) 25 26 func New() provisioners.Interface { 27 ctx, cancel := context.WithCancel(context.Background()) 28 return &provisioner{ 29 ctx: ctx, 30 cancel: cancel, 31 } 32 } 33 34 type provisioner struct { 35 // We store a context here tied to the lifetime of the provisioner. 36 // This allows the Stop method to cancel any in-flight requests. 37 ctx context.Context 38 cancel context.CancelFunc 39 } 40 41 func (p *provisioner) GetSchema() (resp provisioners.GetSchemaResponse) { 42 schema := &configschema.Block{ 43 Attributes: map[string]*configschema.Attribute{ 44 "command": { 45 Type: cty.String, 46 Required: true, 47 }, 48 "interpreter": { 49 Type: cty.List(cty.String), 50 Optional: true, 51 }, 52 "working_dir": { 53 Type: cty.String, 54 Optional: true, 55 }, 56 "environment": { 57 Type: cty.Map(cty.String), 58 Optional: true, 59 }, 60 }, 61 } 62 63 resp.Provisioner = schema 64 return resp 65 } 66 67 func (p *provisioner) ValidateProvisionerConfig(req provisioners.ValidateProvisionerConfigRequest) (resp provisioners.ValidateProvisionerConfigResponse) { 68 if _, err := p.GetSchema().Provisioner.CoerceValue(req.Config); err != nil { 69 resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody( 70 tfdiags.Error, 71 "Invalid local-exec provisioner configuration", 72 err.Error(), 73 )) 74 } 75 return resp 76 } 77 78 func (p *provisioner) ProvisionResource(req provisioners.ProvisionResourceRequest) (resp provisioners.ProvisionResourceResponse) { 79 command := req.Config.GetAttr("command").AsString() 80 if command == "" { 81 resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody( 82 tfdiags.Error, 83 "Invalid local-exec provisioner command", 84 "The command must be a non-empty string.", 85 )) 86 return resp 87 } 88 89 envVal := req.Config.GetAttr("environment") 90 var env []string 91 92 if !envVal.IsNull() { 93 for k, v := range envVal.AsValueMap() { 94 if !v.IsNull() { 95 entry := fmt.Sprintf("%s=%s", k, v.AsString()) 96 env = append(env, entry) 97 } 98 } 99 } 100 101 // Execute the command using a shell 102 intrVal := req.Config.GetAttr("interpreter") 103 104 var cmdargs []string 105 if !intrVal.IsNull() && intrVal.LengthInt() > 0 { 106 for _, v := range intrVal.AsValueSlice() { 107 if !v.IsNull() { 108 cmdargs = append(cmdargs, v.AsString()) 109 } 110 } 111 } else { 112 if runtime.GOOS == "windows" { 113 cmdargs = []string{"cmd", "/C"} 114 } else { 115 cmdargs = []string{"/bin/sh", "-c"} 116 } 117 } 118 119 cmdargs = append(cmdargs, command) 120 121 workingdir := "" 122 if wdVal := req.Config.GetAttr("working_dir"); !wdVal.IsNull() { 123 workingdir = wdVal.AsString() 124 } 125 126 // Set up the reader that will read the output from the command. 127 // We use an os.Pipe so that the *os.File can be passed directly to the 128 // process, and not rely on goroutines copying the data which may block. 129 // See golang.org/issue/18874 130 pr, pw, err := os.Pipe() 131 if err != nil { 132 resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody( 133 tfdiags.Error, 134 "local-exec provisioner error", 135 fmt.Sprintf("Failed to initialize pipe for output: %s", err), 136 )) 137 return resp 138 } 139 140 var cmdEnv []string 141 cmdEnv = os.Environ() 142 cmdEnv = append(cmdEnv, env...) 143 144 // Set up the command 145 cmd := exec.CommandContext(p.ctx, cmdargs[0], cmdargs[1:]...) 146 cmd.Stderr = pw 147 cmd.Stdout = pw 148 // Dir specifies the working directory of the command. 149 // If Dir is the empty string (this is default), runs the command 150 // in the calling process's current directory. 151 cmd.Dir = workingdir 152 // Env specifies the environment of the command. 153 // By default will use the calling process's environment 154 cmd.Env = cmdEnv 155 156 output, _ := circbuf.NewBuffer(maxBufSize) 157 158 // Write everything we read from the pipe to the output buffer too 159 tee := io.TeeReader(pr, output) 160 161 // copy the teed output to the UI output 162 copyDoneCh := make(chan struct{}) 163 go copyUIOutput(req.UIOutput, tee, copyDoneCh) 164 165 // Output what we're about to run 166 req.UIOutput.Output(fmt.Sprintf("Executing: %q", cmdargs)) 167 168 // Start the command 169 err = cmd.Start() 170 if err == nil { 171 err = cmd.Wait() 172 } 173 174 // Close the write-end of the pipe so that the goroutine mirroring output 175 // ends properly. 176 pw.Close() 177 178 // Cancelling the command may block the pipe reader if the file descriptor 179 // was passed to a child process which hasn't closed it. In this case the 180 // copyOutput goroutine will just hang out until exit. 181 select { 182 case <-copyDoneCh: 183 case <-p.ctx.Done(): 184 } 185 186 if err != nil { 187 resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody( 188 tfdiags.Error, 189 "local-exec provisioner error", 190 fmt.Sprintf("Error running command '%s': %v. Output: %s", command, err, output.Bytes()), 191 )) 192 return resp 193 } 194 195 return resp 196 } 197 198 func (p *provisioner) Stop() error { 199 p.cancel() 200 return nil 201 } 202 203 func (p *provisioner) Close() error { 204 return nil 205 } 206 207 func copyUIOutput(o provisioners.UIOutput, r io.Reader, doneCh chan<- struct{}) { 208 defer close(doneCh) 209 lr := linereader.New(r) 210 for line := range lr.Ch { 211 o.Output(line) 212 } 213 }