github.com/crossplane/upjet@v1.3.0/pkg/terraform/workspace.go (about) 1 // SPDX-FileCopyrightText: 2023 The Crossplane Authors <https://crossplane.io> 2 // 3 // SPDX-License-Identifier: Apache-2.0 4 5 package terraform 6 7 import ( 8 "context" 9 "fmt" 10 "os" 11 "path/filepath" 12 "strings" 13 "sync" 14 "time" 15 16 "github.com/crossplane/crossplane-runtime/pkg/logging" 17 "github.com/pkg/errors" 18 "github.com/spf13/afero" 19 k8sExec "k8s.io/utils/exec" 20 21 "github.com/crossplane/upjet/pkg/metrics" 22 "github.com/crossplane/upjet/pkg/resource" 23 "github.com/crossplane/upjet/pkg/resource/json" 24 tferrors "github.com/crossplane/upjet/pkg/terraform/errors" 25 ) 26 27 const ( 28 defaultAsyncTimeout = 1 * time.Hour 29 envReattachConfig = "TF_REATTACH_PROVIDERS" 30 fmtEnv = "%s=%s" 31 ) 32 33 // ExecMode is the Terraform CLI execution mode label 34 type ExecMode int 35 36 const ( 37 // ModeSync represents the synchronous execution mode 38 ModeSync ExecMode = iota 39 // ModeASync represents the asynchronous execution mode 40 ModeASync 41 ) 42 43 // String converts an execMode to string 44 func (em ExecMode) String() string { 45 switch em { 46 case ModeSync: 47 return "sync" 48 case ModeASync: 49 return "async" 50 default: 51 return "unknown" 52 } 53 } 54 55 // WorkspaceOption allows you to configure Workspace objects. 56 type WorkspaceOption func(*Workspace) 57 58 // WithLogger sets the logger of Workspace. 59 func WithLogger(l logging.Logger) WorkspaceOption { 60 return func(w *Workspace) { 61 w.logger = l 62 } 63 } 64 65 // WithExecutor sets the executor of Workspace. 66 func WithExecutor(e k8sExec.Interface) WorkspaceOption { 67 return func(w *Workspace) { 68 w.executor = e 69 } 70 } 71 72 // WithLastOperation sets the Last Operation of Workspace. 73 func WithLastOperation(lo *Operation) WorkspaceOption { 74 return func(w *Workspace) { 75 w.LastOperation = lo 76 } 77 } 78 79 // WithAferoFs lets you set the fs of WorkspaceStore. 80 func WithAferoFs(fs afero.Fs) WorkspaceOption { 81 return func(ws *Workspace) { 82 ws.fs = afero.Afero{Fs: fs} 83 } 84 } 85 86 // WithFilterFn configures the debug log sensitive information filtering func. 87 func WithFilterFn(filterFn func(string) string) WorkspaceOption { 88 return func(w *Workspace) { 89 w.filterFn = filterFn 90 } 91 } 92 93 // WithProviderInUse configures an InUse for keeping track of 94 // the shared provider InUse by this Terraform workspace. 95 func WithProviderInUse(providerInUse InUse) WorkspaceOption { 96 return func(w *Workspace) { 97 w.providerInUse = providerInUse 98 } 99 } 100 101 // NewWorkspace returns a new Workspace object that operates in the given 102 // directory. 103 func NewWorkspace(dir string, opts ...WorkspaceOption) *Workspace { 104 w := &Workspace{ 105 LastOperation: &Operation{}, 106 dir: dir, 107 logger: logging.NewNopLogger(), 108 fs: afero.Afero{Fs: afero.NewOsFs()}, 109 providerInUse: noopInUse{}, 110 mu: &sync.Mutex{}, 111 } 112 for _, f := range opts { 113 f(w) 114 } 115 return w 116 } 117 118 // CallbackFn is the type of accepted function that can be called after an async 119 // operation is completed. 120 type CallbackFn func(error, context.Context) error 121 122 // Workspace runs Terraform operations in its directory and holds the information 123 // about their statuses. 124 type Workspace struct { 125 // LastOperation contains information about the last operation performed. 126 LastOperation *Operation 127 // ProviderHandle is the handle of the associated native Terraform provider 128 // computed from the generated provider resource configuration block 129 // of the Terraform workspace. 130 ProviderHandle ProviderHandle 131 132 dir string 133 env []string 134 135 logger logging.Logger 136 executor k8sExec.Interface 137 providerInUse InUse 138 fs afero.Afero 139 mu *sync.Mutex 140 141 filterFn func(string) string 142 143 terraformID string 144 } 145 146 // UseProvider shares a native provider with the receiver Workspace. 147 func (w *Workspace) UseProvider(inuse InUse, attachmentConfig string) { 148 w.mu.Lock() 149 defer w.mu.Unlock() 150 // remove existing reattach configs 151 env := make([]string, 0, len(w.env)) 152 prefix := fmt.Sprintf(fmtEnv, envReattachConfig, "") 153 for _, e := range w.env { 154 if !strings.HasPrefix(e, prefix) { 155 env = append(env, e) 156 } 157 } 158 env = append(env, prefix+attachmentConfig) 159 w.env = env 160 w.providerInUse = inuse 161 } 162 163 // ApplyAsync makes a terraform apply call without blocking and calls the given 164 // function once that apply call finishes. 165 func (w *Workspace) ApplyAsync(callback CallbackFn) error { 166 if !w.LastOperation.MarkStart("apply") { 167 return errors.Errorf("%s operation that started at %s is still running", w.LastOperation.Type, w.LastOperation.StartTime().String()) 168 } 169 ctx, cancel := context.WithDeadline(context.TODO(), w.LastOperation.StartTime().Add(defaultAsyncTimeout)) 170 w.providerInUse.Increment() 171 go func() { 172 defer cancel() 173 out, err := w.runTF(ctx, ModeASync, "apply", "-auto-approve", "-input=false", "-lock=false", "-json") 174 if err != nil { 175 err = tferrors.NewApplyFailed(out) 176 } 177 w.LastOperation.MarkEnd() 178 w.logger.Debug("apply async ended", "out", w.filterFn(string(out))) 179 defer func() { 180 if cErr := callback(err, ctx); cErr != nil { 181 w.logger.Info("callback failed", "error", cErr.Error()) 182 } 183 }() 184 }() 185 return nil 186 } 187 188 // ApplyResult contains the state after the apply operation. 189 type ApplyResult struct { 190 State *json.StateV4 191 } 192 193 // Apply makes a blocking terraform apply call. 194 func (w *Workspace) Apply(ctx context.Context) (ApplyResult, error) { 195 if w.LastOperation.IsRunning() { 196 return ApplyResult{}, errors.Errorf("%s operation that started at %s is still running", w.LastOperation.Type, w.LastOperation.StartTime().String()) 197 } 198 out, err := w.runTF(ctx, ModeSync, "apply", "-auto-approve", "-input=false", "-lock=false", "-json") 199 w.logger.Debug("apply ended", "out", w.filterFn(string(out))) 200 if err != nil { 201 return ApplyResult{}, tferrors.NewApplyFailed(out) 202 } 203 raw, err := w.fs.ReadFile(filepath.Join(w.dir, "terraform.tfstate")) 204 if err != nil { 205 return ApplyResult{}, errors.Wrap(err, "cannot read terraform state file") 206 } 207 s := &json.StateV4{} 208 if err := json.JSParser.Unmarshal(raw, s); err != nil { 209 return ApplyResult{}, errors.Wrap(err, "cannot unmarshal tfstate file") 210 } 211 return ApplyResult{State: s}, nil 212 } 213 214 // DestroyAsync makes a non-blocking terraform destroy call. It doesn't accept 215 // a callback because destroy operations are not time sensitive as ApplyAsync 216 // where you might need to store the server-side computed information as soon 217 // as possible. 218 func (w *Workspace) DestroyAsync(callback CallbackFn) error { 219 switch { 220 // Destroy call is idempotent and can be called repeatedly. 221 case w.LastOperation.Type == "destroy": 222 return nil 223 // We cannot run destroy until current non-destroy operation is completed. 224 // TODO(muvaf): Gracefully terminate the ongoing apply operation? 225 case !w.LastOperation.MarkStart("destroy"): 226 return errors.Errorf("%s operation that started at %s is still running", w.LastOperation.Type, w.LastOperation.StartTime().String()) 227 } 228 ctx, cancel := context.WithDeadline(context.TODO(), w.LastOperation.StartTime().Add(defaultAsyncTimeout)) 229 w.providerInUse.Increment() 230 go func() { 231 defer cancel() 232 out, err := w.runTF(ctx, ModeASync, "destroy", "-auto-approve", "-input=false", "-lock=false", "-json") 233 if err != nil { 234 err = tferrors.NewDestroyFailed(out) 235 } 236 w.LastOperation.MarkEnd() 237 w.logger.Debug("destroy async ended", "out", w.filterFn(string(out))) 238 defer func() { 239 if cErr := callback(err, ctx); cErr != nil { 240 w.logger.Info("callback failed", "error", cErr.Error()) 241 } 242 }() 243 }() 244 return nil 245 } 246 247 // Destroy makes a blocking terraform destroy call. 248 func (w *Workspace) Destroy(ctx context.Context) error { 249 if w.LastOperation.IsRunning() { 250 return errors.Errorf("%s operation that started at %s is still running", w.LastOperation.Type, w.LastOperation.StartTime().String()) 251 } 252 out, err := w.runTF(ctx, ModeSync, "destroy", "-auto-approve", "-input=false", "-lock=false", "-json") 253 w.logger.Debug("destroy ended", "out", w.filterFn(string(out))) 254 if err != nil { 255 return tferrors.NewDestroyFailed(out) 256 } 257 return nil 258 } 259 260 // RefreshResult contains information about the current state of the resource. 261 type RefreshResult struct { 262 Exists bool 263 ASyncInProgress bool 264 State *json.StateV4 265 } 266 267 // Refresh makes a blocking terraform apply -refresh-only call where only the state file 268 // is changed with the current state of the resource. 269 func (w *Workspace) Refresh(ctx context.Context) (RefreshResult, error) { 270 switch { 271 case w.LastOperation.IsRunning(): 272 return RefreshResult{ 273 ASyncInProgress: w.LastOperation.Type == "apply" || w.LastOperation.Type == "destroy", 274 }, nil 275 case w.LastOperation.IsEnded(): 276 defer w.LastOperation.Flush() 277 } 278 out, err := w.runTF(ctx, ModeSync, "apply", "-refresh-only", "-auto-approve", "-input=false", "-lock=false", "-json") 279 w.logger.Debug("refresh ended", "out", w.filterFn(string(out))) 280 if err != nil { 281 return RefreshResult{}, tferrors.NewRefreshFailed(out) 282 } 283 raw, err := w.fs.ReadFile(filepath.Join(w.dir, "terraform.tfstate")) 284 if err != nil { 285 return RefreshResult{}, errors.Wrap(err, "cannot read terraform state file") 286 } 287 s := &json.StateV4{} 288 if err := json.JSParser.Unmarshal(raw, s); err != nil { 289 return RefreshResult{}, errors.Wrap(err, "cannot unmarshal tfstate file") 290 } 291 return RefreshResult{ 292 Exists: s.GetAttributes() != nil, 293 State: s, 294 }, nil 295 } 296 297 // PlanResult returns a summary of comparison between desired and current state 298 // of the resource. 299 type PlanResult struct { 300 Exists bool 301 UpToDate bool 302 } 303 304 // Plan makes a blocking terraform plan call. 305 func (w *Workspace) Plan(ctx context.Context) (PlanResult, error) { 306 // The last operation is still ongoing. 307 if w.LastOperation.IsRunning() { 308 return PlanResult{}, errors.Errorf("%s operation that started at %s is still running", w.LastOperation.Type, w.LastOperation.StartTime().String()) 309 } 310 out, err := w.runTF(ctx, ModeSync, "plan", "-refresh=false", "-input=false", "-lock=false", "-json") 311 w.logger.Debug("plan ended", "out", w.filterFn(string(out))) 312 if err != nil { 313 return PlanResult{}, tferrors.NewPlanFailed(out) 314 } 315 line := "" 316 for _, l := range strings.Split(string(out), "\n") { 317 if strings.Contains(l, `"type":"change_summary"`) { 318 line = l 319 break 320 } 321 } 322 if line == "" { 323 return PlanResult{}, errors.Errorf("cannot find the change summary line in plan log: %s", string(out)) 324 } 325 type plan struct { 326 Changes struct { 327 Add float64 `json:"add,omitempty"` 328 Change float64 `json:"change,omitempty"` 329 } `json:"changes,omitempty"` 330 } 331 p := &plan{} 332 if err := json.JSParser.Unmarshal([]byte(line), p); err != nil { 333 return PlanResult{}, errors.Wrap(err, "cannot unmarshal change summary json") 334 } 335 return PlanResult{ 336 Exists: p.Changes.Add == 0, 337 UpToDate: p.Changes.Change == 0, 338 }, nil 339 } 340 341 // ImportResult contains information about the current state of the resource. 342 // Same as RefreshResult. 343 type ImportResult RefreshResult 344 345 // Import makes a blocking terraform import call where only the state file 346 // is changed with the current state of the resource. 347 func (w *Workspace) Import(ctx context.Context, tr resource.Terraformed) (ImportResult, error) { //nolint:gocyclo 348 switch { 349 case w.LastOperation.IsRunning(): 350 return ImportResult{ 351 ASyncInProgress: w.LastOperation.Type == "apply" || w.LastOperation.Type == "destroy", 352 }, nil 353 case w.LastOperation.IsEnded(): 354 defer w.LastOperation.Flush() 355 } 356 // Note(turkenh): This resource does not have an ID, we cannot import it. This happens with identifier from 357 // provider case, and we simply return does not exist in this case. 358 if len(w.terraformID) == 0 { 359 return ImportResult{ 360 Exists: false, 361 }, nil 362 } 363 364 // Note(turkenh): We remove the state file since the import command wouldn't work if tfstate contains 365 // the resource already. 366 if err := w.fs.Remove(filepath.Join(w.dir, "terraform.tfstate")); err != nil && !os.IsNotExist(err) { 367 return ImportResult{}, errors.Wrap(err, "cannot remove terraform.tfstate file") 368 } 369 370 out, err := w.runTF(ctx, ModeSync, "import", "-input=false", "-lock=false", fmt.Sprintf("%s.%s", tr.GetTerraformResourceType(), tr.GetName()), w.terraformID) 371 w.logger.Debug("import ended", "out", w.filterFn(string(out))) 372 if err != nil { 373 // Note(turkenh): This is not a great way to check if the resource does not exist, but it is the only 374 // way we can do it for now. Terraform import does not return a proper exit code for this case or 375 // does not support -json flag to parse the returning error in a better way. 376 // https://github.com/hashicorp/terraform/blob/93f9cff99ffbb8d536b276a1be40a2c45ca4a67f/internal/terraform/node_resource_import.go#L235 377 if strings.Contains(string(out), "Cannot import non-existent remote object") { 378 return ImportResult{ 379 Exists: false, 380 }, nil 381 } 382 return ImportResult{}, errors.WithMessage(errors.New("import failed"), w.filterFn(string(out))) 383 } 384 raw, err := w.fs.ReadFile(filepath.Join(w.dir, "terraform.tfstate")) 385 if err != nil { 386 return ImportResult{}, errors.Wrap(err, "cannot read terraform state file") 387 } 388 s := &json.StateV4{} 389 if err := json.JSParser.Unmarshal(raw, s); err != nil { 390 return ImportResult{}, errors.Wrap(err, "cannot unmarshal tfstate file") 391 } 392 return ImportResult{ 393 Exists: s.GetAttributes() != nil, 394 State: s, 395 }, nil 396 } 397 398 func (w *Workspace) runTF(ctx context.Context, execMode ExecMode, args ...string) ([]byte, error) { 399 if len(args) < 1 { 400 return nil, errors.New("args cannot be empty") 401 } 402 w.logger.Debug("Running terraform", "args", args) 403 if execMode == ModeSync { 404 w.providerInUse.Increment() 405 } 406 defer w.providerInUse.Decrement() 407 w.mu.Lock() 408 defer w.mu.Unlock() 409 cmd := w.executor.CommandContext(ctx, "terraform", args...) 410 cmd.SetEnv(append(os.Environ(), w.env...)) 411 cmd.SetDir(w.dir) 412 metrics.CLIExecutions.WithLabelValues(args[0], execMode.String()).Inc() 413 start := time.Now() 414 defer func() { 415 metrics.CLITime.WithLabelValues(args[0], execMode.String()).Observe(time.Since(start).Seconds()) 416 metrics.CLIExecutions.WithLabelValues(args[0], execMode.String()).Dec() 417 }() 418 return cmd.CombinedOutput() 419 }