github.com/opentofu/opentofu@v1.7.1/internal/builtin/provisioners/remote-exec/resource_provisioner.go (about) 1 // Copyright (c) The OpenTofu Authors 2 // SPDX-License-Identifier: MPL-2.0 3 // Copyright (c) 2023 HashiCorp, Inc. 4 // SPDX-License-Identifier: MPL-2.0 5 6 package remoteexec 7 8 import ( 9 "bytes" 10 "context" 11 "errors" 12 "fmt" 13 "io" 14 "log" 15 "os" 16 "strings" 17 18 "github.com/mitchellh/go-linereader" 19 "github.com/opentofu/opentofu/internal/communicator" 20 "github.com/opentofu/opentofu/internal/communicator/remote" 21 "github.com/opentofu/opentofu/internal/configs/configschema" 22 "github.com/opentofu/opentofu/internal/provisioners" 23 "github.com/opentofu/opentofu/internal/tfdiags" 24 "github.com/zclconf/go-cty/cty" 25 ) 26 27 func New() provisioners.Interface { 28 ctx, cancel := context.WithCancel(context.Background()) 29 return &provisioner{ 30 ctx: ctx, 31 cancel: cancel, 32 } 33 } 34 35 type provisioner struct { 36 // We store a context here tied to the lifetime of the provisioner. 37 // This allows the Stop method to cancel any in-flight requests. 38 ctx context.Context 39 cancel context.CancelFunc 40 } 41 42 func (p *provisioner) GetSchema() (resp provisioners.GetSchemaResponse) { 43 schema := &configschema.Block{ 44 Attributes: map[string]*configschema.Attribute{ 45 "inline": { 46 Type: cty.List(cty.String), 47 Optional: true, 48 }, 49 "script": { 50 Type: cty.String, 51 Optional: true, 52 }, 53 "scripts": { 54 Type: cty.List(cty.String), 55 Optional: true, 56 }, 57 }, 58 } 59 60 resp.Provisioner = schema 61 return resp 62 } 63 64 func (p *provisioner) ValidateProvisionerConfig(req provisioners.ValidateProvisionerConfigRequest) (resp provisioners.ValidateProvisionerConfigResponse) { 65 cfg, err := p.GetSchema().Provisioner.CoerceValue(req.Config) 66 if err != nil { 67 resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody( 68 tfdiags.Error, 69 "Invalid remote-exec provisioner configuration", 70 err.Error(), 71 )) 72 return resp 73 } 74 75 inline := cfg.GetAttr("inline") 76 script := cfg.GetAttr("script") 77 scripts := cfg.GetAttr("scripts") 78 79 set := 0 80 if !inline.IsNull() { 81 set++ 82 } 83 if !script.IsNull() { 84 set++ 85 } 86 if !scripts.IsNull() { 87 set++ 88 } 89 if set != 1 { 90 resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody( 91 tfdiags.Error, 92 "Invalid remote-exec provisioner configuration", 93 `Only one of "inline", "script", or "scripts" must be set`, 94 )) 95 } 96 return resp 97 } 98 99 func (p *provisioner) ProvisionResource(req provisioners.ProvisionResourceRequest) (resp provisioners.ProvisionResourceResponse) { 100 if req.Connection.IsNull() { 101 resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody( 102 tfdiags.Error, 103 "remote-exec provisioner error", 104 "Missing connection configuration for provisioner.", 105 )) 106 return resp 107 } 108 109 comm, err := communicator.New(req.Connection) 110 if err != nil { 111 resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody( 112 tfdiags.Error, 113 "remote-exec provisioner error", 114 err.Error(), 115 )) 116 return resp 117 } 118 119 // Collect the scripts 120 scripts, err := collectScripts(req.Config) 121 if err != nil { 122 resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody( 123 tfdiags.Error, 124 "remote-exec provisioner error", 125 err.Error(), 126 )) 127 return resp 128 } 129 for _, s := range scripts { 130 defer s.Close() 131 } 132 133 // Copy and execute each script 134 if err := runScripts(p.ctx, req.UIOutput, comm, scripts); err != nil { 135 resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody( 136 tfdiags.Error, 137 "remote-exec provisioner error", 138 err.Error(), 139 )) 140 return resp 141 } 142 143 return resp 144 } 145 146 func (p *provisioner) Stop() error { 147 p.cancel() 148 return nil 149 } 150 151 func (p *provisioner) Close() error { 152 return nil 153 } 154 155 // generateScripts takes the configuration and creates a script from each inline config 156 func generateScripts(inline cty.Value) ([]string, error) { 157 var lines []string 158 for _, l := range inline.AsValueSlice() { 159 if l.IsNull() { 160 return nil, errors.New("invalid null string in 'scripts'") 161 } 162 163 s := l.AsString() 164 if s == "" { 165 return nil, errors.New("invalid empty string in 'scripts'") 166 } 167 lines = append(lines, s) 168 } 169 lines = append(lines, "") 170 171 return []string{strings.Join(lines, "\n")}, nil 172 } 173 174 // collectScripts is used to collect all the scripts we need 175 // to execute in preparation for copying them. 176 func collectScripts(v cty.Value) ([]io.ReadCloser, error) { 177 // Check if inline 178 if inline := v.GetAttr("inline"); !inline.IsNull() { 179 scripts, err := generateScripts(inline) 180 if err != nil { 181 return nil, err 182 } 183 184 var r []io.ReadCloser 185 for _, script := range scripts { 186 r = append(r, io.NopCloser(bytes.NewReader([]byte(script)))) 187 } 188 189 return r, nil 190 } 191 192 // Collect scripts 193 var scripts []string 194 if script := v.GetAttr("script"); !script.IsNull() { 195 s := script.AsString() 196 if s == "" { 197 return nil, errors.New("invalid empty string in 'script'") 198 } 199 scripts = append(scripts, s) 200 } 201 202 if scriptList := v.GetAttr("scripts"); !scriptList.IsNull() { 203 for _, script := range scriptList.AsValueSlice() { 204 if script.IsNull() { 205 return nil, errors.New("invalid null string in 'script'") 206 } 207 s := script.AsString() 208 if s == "" { 209 return nil, errors.New("invalid empty string in 'script'") 210 } 211 scripts = append(scripts, s) 212 } 213 } 214 215 // Open all the scripts 216 var fhs []io.ReadCloser 217 for _, s := range scripts { 218 fh, err := os.Open(s) 219 if err != nil { 220 for _, fh := range fhs { 221 fh.Close() 222 } 223 return nil, fmt.Errorf("Failed to open script '%s': %w", s, err) 224 } 225 fhs = append(fhs, fh) 226 } 227 228 // Done, return the file handles 229 return fhs, nil 230 } 231 232 // runScripts is used to copy and execute a set of scripts 233 func runScripts(ctx context.Context, o provisioners.UIOutput, comm communicator.Communicator, scripts []io.ReadCloser) error { 234 retryCtx, cancel := context.WithTimeout(ctx, comm.Timeout()) 235 defer cancel() 236 237 // Wait and retry until we establish the connection 238 err := communicator.Retry(retryCtx, func() error { 239 return comm.Connect(o) 240 }) 241 if err != nil { 242 return err 243 } 244 245 // Wait for the context to end and then disconnect 246 go func() { 247 <-ctx.Done() 248 comm.Disconnect() 249 }() 250 251 for _, script := range scripts { 252 var cmd *remote.Cmd 253 254 outR, outW := io.Pipe() 255 errR, errW := io.Pipe() 256 defer outW.Close() 257 defer errW.Close() 258 259 go copyUIOutput(o, outR) 260 go copyUIOutput(o, errR) 261 262 remotePath := comm.ScriptPath() 263 264 if err := comm.UploadScript(remotePath, script); err != nil { 265 return fmt.Errorf("Failed to upload script: %w", err) 266 } 267 268 cmd = &remote.Cmd{ 269 Command: remotePath, 270 Stdout: outW, 271 Stderr: errW, 272 } 273 if err := comm.Start(cmd); err != nil { 274 return fmt.Errorf("Error starting script: %w", err) 275 } 276 277 if err := cmd.Wait(); err != nil { 278 return err 279 } 280 281 // Upload a blank follow up file in the same path to prevent residual 282 // script contents from remaining on remote machine 283 empty := bytes.NewReader([]byte("")) 284 if err := comm.Upload(remotePath, empty); err != nil { 285 // This feature is best-effort. 286 log.Printf("[WARN] Failed to upload empty follow up script: %v", err) 287 } 288 } 289 290 return nil 291 } 292 293 func copyUIOutput(o provisioners.UIOutput, r io.Reader) { 294 lr := linereader.New(r) 295 for line := range lr.Ch { 296 o.Output(line) 297 } 298 }