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  }