github.com/crossplane/upjet@v1.3.0/pkg/terraform/files.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  	iofs "io/fs"
    11  	"path/filepath"
    12  	"strings"
    13  
    14  	"dario.cat/mergo"
    15  	"github.com/crossplane/crossplane-runtime/pkg/feature"
    16  	"github.com/crossplane/crossplane-runtime/pkg/meta"
    17  	"github.com/pkg/errors"
    18  	"github.com/spf13/afero"
    19  
    20  	"github.com/crossplane/upjet/pkg/config"
    21  	"github.com/crossplane/upjet/pkg/resource"
    22  	"github.com/crossplane/upjet/pkg/resource/json"
    23  )
    24  
    25  const (
    26  	errWriteTFStateFile  = "cannot write terraform.tfstate file"
    27  	errWriteMainTFFile   = "cannot write main.tf.json file"
    28  	errCheckIfStateEmpty = "cannot check whether the state is empty"
    29  	errMarshalAttributes = "cannot marshal produced state attributes"
    30  	errInsertTimeouts    = "cannot insert timeouts metadata to private raw"
    31  	errReadTFState       = "cannot read terraform.tfstate file"
    32  	errMarshalState      = "cannot marshal state object"
    33  	errUnmarshalAttr     = "cannot unmarshal state attributes"
    34  	errUnmarshalTFState  = "cannot unmarshal tfstate file"
    35  	errFmtNonString      = "cannot work with a non-string id: %s"
    36  	errReadMainTF        = "cannot read main.tf.json file"
    37  )
    38  
    39  // FileProducerOption allows you to configure FileProducer
    40  type FileProducerOption func(*FileProducer)
    41  
    42  // WithFileSystem configures the filesystem to use. Used mostly for testing.
    43  func WithFileSystem(fs afero.Fs) FileProducerOption {
    44  	return func(fp *FileProducer) {
    45  		fp.fs = afero.Afero{Fs: fs}
    46  	}
    47  }
    48  
    49  // WithFileProducerFeatures configures the active features for the FileProducer.
    50  func WithFileProducerFeatures(f *feature.Flags) FileProducerOption {
    51  	return func(fp *FileProducer) {
    52  		fp.features = f
    53  	}
    54  }
    55  
    56  // NewFileProducer returns a new FileProducer.
    57  func NewFileProducer(ctx context.Context, client resource.SecretClient, dir string, tr resource.Terraformed, ts Setup, cfg *config.Resource, opts ...FileProducerOption) (*FileProducer, error) {
    58  	fp := &FileProducer{
    59  		Resource: tr,
    60  		Setup:    ts,
    61  		Dir:      dir,
    62  		Config:   cfg,
    63  		fs:       afero.Afero{Fs: afero.NewOsFs()},
    64  		features: &feature.Flags{},
    65  	}
    66  	for _, f := range opts {
    67  		f(fp)
    68  	}
    69  
    70  	params, err := tr.GetParameters()
    71  	if err != nil {
    72  		return nil, errors.Wrap(err, "cannot get parameters")
    73  	}
    74  
    75  	// Note(lsviben):We need to check if the management policies feature is
    76  	// enabled before attempting to get the ignorable fields or merge them
    77  	// with the forProvider fields.
    78  	if fp.features.Enabled(feature.EnableBetaManagementPolicies) {
    79  		initParams, err := tr.GetInitParameters()
    80  		if err != nil {
    81  			return nil, errors.Wrapf(err, "cannot get the init parameters for the resource %q", tr.GetName())
    82  		}
    83  
    84  		// get fields which should be in the ignore_changes lifecycle block
    85  		fp.ignored = resource.GetTerraformIgnoreChanges(params, initParams)
    86  
    87  		// Note(lsviben): mergo.WithSliceDeepCopy is needed to merge the
    88  		// slices from the initProvider to forProvider. As it also sets
    89  		// overwrite to true, we need to set it back to false, we don't
    90  		// want to overwrite the forProvider fields with the initProvider
    91  		// fields.
    92  		err = mergo.Merge(&params, initParams, mergo.WithSliceDeepCopy, func(c *mergo.Config) {
    93  			c.Overwrite = false
    94  		})
    95  		if err != nil {
    96  			return nil, errors.Wrapf(err, "cannot merge the spec.initProvider and spec.forProvider parameters for the resource %q", tr.GetName())
    97  		}
    98  	}
    99  
   100  	if err = resource.GetSensitiveParameters(ctx, client, tr, params, tr.GetConnectionDetailsMapping()); err != nil {
   101  		return nil, errors.Wrap(err, "cannot get sensitive parameters")
   102  	}
   103  	fp.Config.ExternalName.SetIdentifierArgumentFn(params, meta.GetExternalName(tr))
   104  	fp.parameters = params
   105  
   106  	obs, err := tr.GetObservation()
   107  	if err != nil {
   108  		return nil, errors.Wrap(err, "cannot get observation")
   109  	}
   110  	if err = resource.GetSensitiveObservation(ctx, client, tr.GetWriteConnectionSecretToReference(), obs); err != nil {
   111  		return nil, errors.Wrap(err, "cannot get sensitive observation")
   112  	}
   113  	fp.observation = obs
   114  
   115  	return fp, nil
   116  }
   117  
   118  // FileProducer exist to serve as cache for the data that is costly to produce
   119  // every time like parameters and observation maps.
   120  type FileProducer struct {
   121  	Resource resource.Terraformed
   122  	Setup    Setup
   123  	Dir      string
   124  	Config   *config.Resource
   125  
   126  	parameters  map[string]any
   127  	observation map[string]any
   128  	ignored     []string
   129  	fs          afero.Afero
   130  	features    *feature.Flags
   131  }
   132  
   133  // BuildMainTF produces the contents of the mainTF file as a map.  This format is conducive to
   134  // inspection for tests.  WriteMainTF calls this function an serializes the result to a file as JSON.
   135  func (fp *FileProducer) BuildMainTF() map[string]any {
   136  	// If the resource is in a deletion process, we need to remove the deletion
   137  	// protection.
   138  	lifecycle := map[string]any{
   139  		"prevent_destroy": !meta.WasDeleted(fp.Resource),
   140  	}
   141  
   142  	if len(fp.ignored) != 0 {
   143  		lifecycle["ignore_changes"] = fp.ignored
   144  	}
   145  
   146  	fp.parameters["lifecycle"] = lifecycle
   147  
   148  	// Add operation timeouts if any timeout configured for the resource
   149  	if tp := timeouts(fp.Config.OperationTimeouts).asParameter(); len(tp) != 0 {
   150  		fp.parameters["timeouts"] = tp
   151  	}
   152  
   153  	// Note(turkenh): To use third party providers, we need to configure
   154  	// provider name in required_providers.
   155  	providerSource := strings.Split(fp.Setup.Requirement.Source, "/")
   156  	return map[string]any{
   157  		"terraform": map[string]any{
   158  			"required_providers": map[string]any{
   159  				providerSource[len(providerSource)-1]: map[string]string{
   160  					"source":  fp.Setup.Requirement.Source,
   161  					"version": fp.Setup.Requirement.Version,
   162  				},
   163  			},
   164  		},
   165  		"provider": map[string]any{
   166  			providerSource[len(providerSource)-1]: fp.Setup.Configuration,
   167  		},
   168  		"resource": map[string]any{
   169  			fp.Resource.GetTerraformResourceType(): map[string]any{
   170  				fp.Resource.GetName(): fp.parameters,
   171  			},
   172  		},
   173  	}
   174  }
   175  
   176  // WriteMainTF writes the content main configuration file that has the desired
   177  // state configuration for Terraform.
   178  func (fp *FileProducer) WriteMainTF() (ProviderHandle, error) {
   179  	m := fp.BuildMainTF()
   180  	rawMainTF, err := json.JSParser.Marshal(m)
   181  	if err != nil {
   182  		return InvalidProviderHandle, errors.Wrap(err, "cannot marshal main hcl object")
   183  	}
   184  	h, err := fp.Setup.Configuration.ToProviderHandle()
   185  	if err != nil {
   186  		return InvalidProviderHandle, errors.Wrap(err, "cannot get scheduler handle")
   187  	}
   188  	return h, errors.Wrap(fp.fs.WriteFile(filepath.Join(fp.Dir, "main.tf.json"), rawMainTF, 0600), errWriteMainTFFile)
   189  }
   190  
   191  // EnsureTFState writes the Terraform state that should exist in the filesystem
   192  // to start any Terraform operation.
   193  func (fp *FileProducer) EnsureTFState(_ context.Context, tfID string) error {
   194  	// TODO(muvaf): Reduce the cyclomatic complexity by separating the attributes
   195  	// generation into its own function/interface.
   196  	empty, err := fp.isStateEmpty()
   197  	if err != nil {
   198  		return errors.Wrap(err, errCheckIfStateEmpty)
   199  	}
   200  	// We don't fill up the TF state during deletion because Terraform's removal
   201  	// of them from the TF state file signals that the deletion was successful.
   202  	// This is especially useful for resources whose deletion are scheduled for
   203  	// a long period of time, where if we fill the ID, the queries would actually
   204  	// succeed, i.e. GCP KMS KeyRing.
   205  	if !empty || meta.WasDeleted(fp.Resource) {
   206  		return nil
   207  	}
   208  	base := make(map[string]any)
   209  	// NOTE(muvaf): Since we try to produce the current state, observation
   210  	// takes precedence over parameters.
   211  	for k, v := range fp.parameters {
   212  		base[k] = v
   213  	}
   214  	for k, v := range fp.observation {
   215  		base[k] = v
   216  	}
   217  	base["id"] = tfID
   218  	attr, err := json.JSParser.Marshal(base)
   219  	if err != nil {
   220  		return errors.Wrap(err, errMarshalAttributes)
   221  	}
   222  	var privateRaw []byte
   223  	if pr, ok := fp.Resource.GetAnnotations()[resource.AnnotationKeyPrivateRawAttribute]; ok {
   224  		privateRaw = []byte(pr)
   225  	}
   226  	if privateRaw, err = insertTimeoutsMeta(privateRaw, timeouts(fp.Config.OperationTimeouts)); err != nil {
   227  		return errors.Wrap(err, errInsertTimeouts)
   228  	}
   229  	s := json.NewStateV4()
   230  	s.TerraformVersion = fp.Setup.Version
   231  	s.Lineage = string(fp.Resource.GetUID())
   232  	s.Resources = []json.ResourceStateV4{
   233  		{
   234  			Mode: "managed",
   235  			Type: fp.Resource.GetTerraformResourceType(),
   236  			Name: fp.Resource.GetName(),
   237  			// TODO(muvaf): we should get the full URL from Dockerfile since
   238  			// providers don't have to be hosted in registry.terraform.io
   239  			ProviderConfig: fmt.Sprintf(`provider["registry.terraform.io/%s"]`, fp.Setup.Requirement.Source),
   240  			Instances: []json.InstanceObjectStateV4{
   241  				{
   242  					SchemaVersion: uint64(fp.Resource.GetTerraformSchemaVersion()),
   243  					PrivateRaw:    privateRaw,
   244  					AttributesRaw: attr,
   245  				},
   246  			},
   247  		},
   248  	}
   249  
   250  	rawState, err := json.JSParser.Marshal(s)
   251  	if err != nil {
   252  		return errors.Wrap(err, errMarshalState)
   253  	}
   254  	return errors.Wrap(fp.fs.WriteFile(filepath.Join(fp.Dir, "terraform.tfstate"), rawState, 0600), errWriteTFStateFile)
   255  }
   256  
   257  // isStateEmpty returns whether the Terraform state includes a resource or not.
   258  func (fp *FileProducer) isStateEmpty() (bool, error) {
   259  	data, err := fp.fs.ReadFile(filepath.Join(fp.Dir, "terraform.tfstate"))
   260  	if errors.Is(err, iofs.ErrNotExist) {
   261  		return true, nil
   262  	}
   263  	if err != nil {
   264  		return false, errors.Wrap(err, errReadTFState)
   265  	}
   266  	s := &json.StateV4{}
   267  	if err := json.JSParser.Unmarshal(data, s); err != nil {
   268  		return false, errors.Wrap(err, errUnmarshalTFState)
   269  	}
   270  	attrData := s.GetAttributes()
   271  	if attrData == nil {
   272  		return true, nil
   273  	}
   274  	attr := map[string]any{}
   275  	if err := json.JSParser.Unmarshal(attrData, &attr); err != nil {
   276  		return false, errors.Wrap(err, errUnmarshalAttr)
   277  	}
   278  	id, ok := attr["id"]
   279  	if !ok {
   280  		return true, nil
   281  	}
   282  	sid, ok := id.(string)
   283  	if !ok {
   284  		return false, errors.Errorf(errFmtNonString, fmt.Sprint(id))
   285  	}
   286  	return sid == "", nil
   287  }
   288  
   289  type MainConfiguration struct {
   290  	Terraform Terraform `json:"terraform,omitempty"`
   291  }
   292  
   293  type Terraform struct {
   294  	RequiredProviders map[string]any `json:"required_providers,omitempty"`
   295  }
   296  
   297  func (fp *FileProducer) needProviderUpgrade() (bool, error) {
   298  	data, err := fp.fs.ReadFile(filepath.Join(fp.Dir, "main.tf.json"))
   299  	if errors.Is(err, iofs.ErrNotExist) {
   300  		return false, nil
   301  	}
   302  	if err != nil {
   303  		return false, errors.Wrap(err, errReadMainTF)
   304  	}
   305  	mainConfiguration := MainConfiguration{}
   306  	if err := json.JSParser.Unmarshal(data, &mainConfiguration); err != nil {
   307  		return false, errors.Wrap(err, errReadMainTF)
   308  	}
   309  	providerSource := strings.Split(fp.Setup.Requirement.Source, "/")
   310  	providerConfiguration, ok := mainConfiguration.Terraform.RequiredProviders[providerSource[len(providerSource)-1]]
   311  	if !ok {
   312  		return false, errors.New("cannot get provider configuration")
   313  	}
   314  	v, ok := providerConfiguration.(map[string]any)["version"]
   315  	if !ok {
   316  		return false, errors.New("cannot get version")
   317  	}
   318  	return v != fp.Setup.Requirement.Version, nil
   319  }