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(¶ms, 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 }