github.com/crossplane/upjet@v1.3.0/pkg/terraform/store.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  	"crypto/sha256"
    10  	"fmt"
    11  	"os"
    12  	"path/filepath"
    13  	"sort"
    14  	"strings"
    15  	"sync"
    16  	"time"
    17  
    18  	"github.com/crossplane/crossplane-runtime/pkg/feature"
    19  	"github.com/crossplane/crossplane-runtime/pkg/logging"
    20  	"github.com/crossplane/crossplane-runtime/pkg/meta"
    21  	xpresource "github.com/crossplane/crossplane-runtime/pkg/resource"
    22  	fwprovider "github.com/hashicorp/terraform-plugin-framework/provider"
    23  	"github.com/mitchellh/go-ps"
    24  	"github.com/pkg/errors"
    25  	"github.com/spf13/afero"
    26  	"k8s.io/apimachinery/pkg/types"
    27  	"k8s.io/utils/exec"
    28  	"sigs.k8s.io/controller-runtime/pkg/client"
    29  
    30  	"github.com/crossplane/upjet/pkg/config"
    31  	"github.com/crossplane/upjet/pkg/metrics"
    32  	"github.com/crossplane/upjet/pkg/resource"
    33  )
    34  
    35  const (
    36  	errGetID = "cannot get id"
    37  )
    38  
    39  // SetupFn is a function that returns Terraform setup which contains
    40  // provider requirement, configuration and Terraform version.
    41  type SetupFn func(ctx context.Context, client client.Client, mg xpresource.Managed) (Setup, error)
    42  
    43  // ProviderRequirement holds values for the Terraform HCL setup requirements
    44  type ProviderRequirement struct {
    45  	// Source of the provider. An example value is "hashicorp/aws".
    46  	Source string
    47  
    48  	// Version of the provider. An example value is "4.0"
    49  	Version string
    50  }
    51  
    52  // ProviderConfiguration holds the setup configuration body
    53  type ProviderConfiguration map[string]any
    54  
    55  // ToProviderHandle converts a provider configuration to a handle
    56  // for the provider scheduler.
    57  func (pc ProviderConfiguration) ToProviderHandle() (ProviderHandle, error) {
    58  	h := strings.Join(getSortedKeyValuePairs("", pc), ",")
    59  	hash := sha256.New()
    60  	if _, err := hash.Write([]byte(h)); err != nil {
    61  		return InvalidProviderHandle, errors.Wrap(err, "cannot convert provider configuration to scheduler handle")
    62  	}
    63  	return ProviderHandle(fmt.Sprintf("%x", hash.Sum(nil))), nil
    64  }
    65  
    66  func getSortedKeyValuePairs(parent string, m map[string]any) []string {
    67  	result := make([]string, 0, len(m))
    68  	sortedKeys := make([]string, 0, len(m))
    69  	for k := range m {
    70  		sortedKeys = append(sortedKeys, k)
    71  	}
    72  	sort.Strings(sortedKeys)
    73  	for _, k := range sortedKeys {
    74  		v := m[k]
    75  		switch t := v.(type) {
    76  		case []string:
    77  			result = append(result, fmt.Sprintf("%q:%q", parent+k, strings.Join(t, ",")))
    78  		case map[string]any:
    79  			result = append(result, getSortedKeyValuePairs(parent+k+".", t)...)
    80  		case []map[string]any:
    81  			cArr := make([]string, 0, len(t))
    82  			for i, e := range t {
    83  				cArr = append(cArr, getSortedKeyValuePairs(fmt.Sprintf("%s%s[%d].", parent, k, i), e)...)
    84  			}
    85  			result = append(result, fmt.Sprintf("%q:%q", parent+k, strings.Join(cArr, ",")))
    86  		case *string:
    87  			if t != nil {
    88  				result = append(result, fmt.Sprintf("%q:%q", parent+k, *t))
    89  			}
    90  		default:
    91  			result = append(result, fmt.Sprintf("%q:%q", parent+k, t))
    92  		}
    93  	}
    94  	return result
    95  }
    96  
    97  // Setup holds values for the Terraform version and setup
    98  // requirements and configuration body
    99  type Setup struct {
   100  	// Version is the version of Terraform that this workspace would require as
   101  	// minimum.
   102  	Version string
   103  
   104  	// Requirement contains the provider requirements of the workspace to work,
   105  	// which is mostly the version and source of the provider.
   106  	Requirement ProviderRequirement
   107  
   108  	// Configuration contains the provider configuration parameters of the given
   109  	// Terraform provider, such as access token.
   110  	Configuration ProviderConfiguration
   111  
   112  	// ClientMetadata contains arbitrary metadata that the provider would like
   113  	// to pass but not available as part of Terraform's provider configuration.
   114  	// For example, AWS account id is needed for certain ID calculations but is
   115  	// not part of the Terraform AWS Provider configuration, so it could be
   116  	// made available only by this map.
   117  	ClientMetadata map[string]string
   118  
   119  	// Scheduler specifies the provider scheduler to be used for the Terraform
   120  	// workspace being setup. If not set, no scheduler is configured and
   121  	// the lifecycle of Terraform provider processes will be managed by
   122  	// the Terraform CLI.
   123  	Scheduler ProviderScheduler
   124  
   125  	Meta any
   126  
   127  	FrameworkProvider fwprovider.Provider
   128  }
   129  
   130  // Map returns the Setup object in map form. The initial reason was so that
   131  // we don't import the terraform package in places where GetIDFn is overridden
   132  // because it can cause circular dependency.
   133  func (s Setup) Map() map[string]any {
   134  	return map[string]any{
   135  		"version": s.Version,
   136  		"requirement": map[string]string{
   137  			"source":  s.Requirement.Source,
   138  			"version": s.Requirement.Version,
   139  		},
   140  		"configuration":   s.Configuration,
   141  		"client_metadata": s.ClientMetadata,
   142  	}
   143  }
   144  
   145  // WorkspaceStoreOption lets you configure the workspace store.
   146  type WorkspaceStoreOption func(*WorkspaceStore)
   147  
   148  // WithFs lets you set the fs of WorkspaceStore. Used mostly for testing.
   149  func WithFs(fs afero.Fs) WorkspaceStoreOption {
   150  	return func(ws *WorkspaceStore) {
   151  		ws.fs = afero.Afero{Fs: fs}
   152  	}
   153  }
   154  
   155  // WithProcessReportInterval enables the upjet.terraform.running_processes
   156  // metric, which periodically reports the total number of Terraform CLI and
   157  // Terraform provider processes in the system.
   158  func WithProcessReportInterval(d time.Duration) WorkspaceStoreOption {
   159  	return func(ws *WorkspaceStore) {
   160  		ws.processReportInterval = d
   161  	}
   162  }
   163  
   164  // WithDisableInit disables `terraform init` invocations in case
   165  // workspace initialization is not needed (e.g., when using the
   166  // shared gRPC server runtime).
   167  func WithDisableInit(disable bool) WorkspaceStoreOption {
   168  	return func(ws *WorkspaceStore) {
   169  		ws.disableInit = disable
   170  	}
   171  }
   172  
   173  // WithFeatures sets the features of the workspace store.
   174  func WithFeatures(f *feature.Flags) WorkspaceStoreOption {
   175  	return func(ws *WorkspaceStore) {
   176  		ws.features = f
   177  	}
   178  }
   179  
   180  // NewWorkspaceStore returns a new WorkspaceStore.
   181  func NewWorkspaceStore(l logging.Logger, opts ...WorkspaceStoreOption) *WorkspaceStore {
   182  	ws := &WorkspaceStore{
   183  		store:    map[types.UID]*Workspace{},
   184  		logger:   l,
   185  		mu:       sync.Mutex{},
   186  		fs:       afero.Afero{Fs: afero.NewOsFs()},
   187  		executor: exec.New(),
   188  		features: &feature.Flags{},
   189  	}
   190  	for _, f := range opts {
   191  		f(ws)
   192  	}
   193  	ws.initMetrics()
   194  	if ws.processReportInterval != 0 {
   195  		go ws.reportTFProcesses(ws.processReportInterval)
   196  	}
   197  	return ws
   198  }
   199  
   200  // WorkspaceStore allows you to manage multiple Terraform workspaces.
   201  type WorkspaceStore struct {
   202  	// store holds information about ongoing operations of given resource.
   203  	// Since there can be multiple calls that add/remove values from the map at
   204  	// the same time, it has to be safe for concurrency since those operations
   205  	// cause rehashing in some cases.
   206  	store                 map[types.UID]*Workspace
   207  	logger                logging.Logger
   208  	mu                    sync.Mutex
   209  	processReportInterval time.Duration
   210  	fs                    afero.Afero
   211  	executor              exec.Interface
   212  	disableInit           bool
   213  	features              *feature.Flags
   214  }
   215  
   216  // Workspace makes sure the Terraform workspace for the given resource is ready
   217  // to be used and returns the Workspace object configured to work in that
   218  // workspace folder in the filesystem.
   219  func (ws *WorkspaceStore) Workspace(ctx context.Context, c resource.SecretClient, tr resource.Terraformed, ts Setup, cfg *config.Resource) (*Workspace, error) { //nolint:gocyclo
   220  	dir := filepath.Join(ws.fs.GetTempDir(""), string(tr.GetUID()))
   221  	if err := ws.fs.MkdirAll(dir, os.ModePerm); err != nil {
   222  		return nil, errors.Wrap(err, "cannot create directory for workspace")
   223  	}
   224  	ws.mu.Lock()
   225  	w, ok := ws.store[tr.GetUID()]
   226  	if !ok {
   227  		l := ws.logger.WithValues("workspace", dir)
   228  		ws.store[tr.GetUID()] = NewWorkspace(dir, WithLogger(l), WithExecutor(ws.executor), WithFilterFn(ts.filterSensitiveInformation))
   229  		w = ws.store[tr.GetUID()]
   230  	}
   231  	ws.mu.Unlock()
   232  	// If there is an ongoing operation, no changes should be made in the
   233  	// workspace files.
   234  	if w.LastOperation.IsRunning() {
   235  		return w, nil
   236  	}
   237  	fp, err := NewFileProducer(ctx, c, dir, tr, ts, cfg, WithFileProducerFeatures(ws.features))
   238  	if err != nil {
   239  		return nil, errors.Wrap(err, "cannot create a new file producer")
   240  	}
   241  
   242  	w.terraformID, err = fp.Config.ExternalName.GetIDFn(ctx, meta.GetExternalName(fp.Resource), fp.parameters, fp.Setup.Map())
   243  	if err != nil {
   244  		return nil, errors.Wrap(err, errGetID)
   245  	}
   246  
   247  	if err := fp.EnsureTFState(ctx, w.terraformID); err != nil {
   248  		return nil, errors.Wrap(err, "cannot ensure tfstate file")
   249  	}
   250  
   251  	isNeedProviderUpgrade := false
   252  	if !ws.disableInit {
   253  		isNeedProviderUpgrade, err = fp.needProviderUpgrade()
   254  		if err != nil {
   255  			return nil, errors.Wrap(err, "cannot check if a Terraform dependency update is required")
   256  		}
   257  	}
   258  
   259  	if w.ProviderHandle, err = fp.WriteMainTF(); err != nil {
   260  		return nil, errors.Wrap(err, "cannot write main tf file")
   261  	}
   262  	if isNeedProviderUpgrade {
   263  		out, err := w.runTF(ctx, ModeSync, "init", "-upgrade", "-input=false")
   264  		w.logger.Debug("init -upgrade ended", "out", ts.filterSensitiveInformation(string(out)))
   265  		if err != nil {
   266  			return w, errors.Wrapf(err, "cannot upgrade workspace: %s", ts.filterSensitiveInformation(string(out)))
   267  		}
   268  	}
   269  	if ws.disableInit {
   270  		return w, nil
   271  	}
   272  	_, err = ws.fs.Stat(filepath.Join(dir, ".terraform.lock.hcl"))
   273  	if xpresource.Ignore(os.IsNotExist, err) != nil {
   274  		return nil, errors.Wrap(err, "cannot stat init lock file")
   275  	}
   276  	// We need to initialize only if the workspace hasn't been initialized yet.
   277  	if !os.IsNotExist(err) {
   278  		return w, nil
   279  	}
   280  	out, err := w.runTF(ctx, ModeSync, "init", "-input=false")
   281  	w.logger.Debug("init ended", "out", ts.filterSensitiveInformation(string(out)))
   282  	return w, errors.Wrapf(err, "cannot init workspace: %s", ts.filterSensitiveInformation(string(out)))
   283  }
   284  
   285  // Remove deletes the workspace directory from the filesystem and erases its
   286  // record from the store.
   287  func (ws *WorkspaceStore) Remove(obj xpresource.Object) error {
   288  	ws.mu.Lock()
   289  	defer ws.mu.Unlock()
   290  	w, ok := ws.store[obj.GetUID()]
   291  	if !ok {
   292  		return nil
   293  	}
   294  	if err := ws.fs.RemoveAll(w.dir); err != nil {
   295  		return errors.Wrap(err, "cannot remove workspace folder")
   296  	}
   297  	delete(ws.store, obj.GetUID())
   298  	return nil
   299  }
   300  
   301  func (ws *WorkspaceStore) initMetrics() {
   302  	for _, mode := range []ExecMode{ModeSync, ModeASync} {
   303  		for _, subcommand := range []string{"init", "apply", "destroy", "plan"} {
   304  			metrics.CLIExecutions.WithLabelValues(subcommand, mode.String()).Set(0)
   305  		}
   306  	}
   307  }
   308  
   309  func (ts Setup) filterSensitiveInformation(s string) string {
   310  	for _, v := range ts.Configuration {
   311  		if str, ok := v.(string); ok && str != "" {
   312  			s = strings.ReplaceAll(s, str, "REDACTED")
   313  		}
   314  	}
   315  	return s
   316  }
   317  
   318  func (ws *WorkspaceStore) reportTFProcesses(interval time.Duration) {
   319  	for _, t := range []string{"cli", "provider"} {
   320  		metrics.TFProcesses.WithLabelValues(t).Set(0)
   321  	}
   322  	t := time.NewTicker(interval)
   323  	for range t.C {
   324  		processes, err := ps.Processes()
   325  		if err != nil {
   326  			ws.logger.Debug("Failed to list processes", "err", err)
   327  			continue
   328  		}
   329  		cliCount, providerCount := 0.0, 0.0
   330  		for _, p := range processes {
   331  			e := p.Executable()
   332  			switch {
   333  			case e == "terraform":
   334  				cliCount++
   335  			case strings.HasPrefix(e, "terraform-"):
   336  				providerCount++
   337  			}
   338  		}
   339  		metrics.TFProcesses.WithLabelValues("cli").Set(cliCount)
   340  		metrics.TFProcesses.WithLabelValues("provider").Set(providerCount)
   341  	}
   342  }