github.com/crossplane/upjet@v1.3.0/pkg/terraform/files_test.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 "path/filepath" 11 "testing" 12 "time" 13 14 "github.com/crossplane/crossplane-runtime/pkg/feature" 15 "github.com/crossplane/crossplane-runtime/pkg/meta" 16 xpfake "github.com/crossplane/crossplane-runtime/pkg/resource/fake" 17 "github.com/crossplane/crossplane-runtime/pkg/test" 18 "github.com/google/go-cmp/cmp" 19 "github.com/pkg/errors" 20 "github.com/spf13/afero" 21 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 22 23 "github.com/crossplane/upjet/pkg/config" 24 "github.com/crossplane/upjet/pkg/resource" 25 "github.com/crossplane/upjet/pkg/resource/fake" 26 "github.com/crossplane/upjet/pkg/resource/json" 27 ) 28 29 const ( 30 dir = "random-dir" 31 ) 32 33 func TestEnsureTFState(t *testing.T) { 34 type args struct { 35 tr resource.Terraformed 36 cfg *config.Resource 37 s Setup 38 fs func() afero.Afero 39 } 40 type want struct { 41 tfstate string 42 err error 43 } 44 empty := `{"version":4,"terraform_version":"","serial":1,"lineage":"","outputs":null,"resources":[]}` 45 now := metav1.Now() 46 cases := map[string]struct { 47 reason string 48 args 49 want 50 }{ 51 "SuccessWrite": { 52 reason: "Standard resources should be able to write everything it has into tfstate file when state is empty", 53 args: args{ 54 tr: &fake.Terraformed{ 55 Managed: xpfake.Managed{ 56 ObjectMeta: metav1.ObjectMeta{ 57 Annotations: map[string]string{ 58 resource.AnnotationKeyPrivateRawAttribute: "privateraw", 59 meta.AnnotationKeyExternalName: "some-id", 60 }, 61 }, 62 }, 63 Parameterizable: fake.Parameterizable{Parameters: map[string]any{ 64 "param": "paramval", 65 }}, 66 Observable: fake.Observable{Observation: map[string]any{ 67 "obs": "obsval", 68 }}, 69 }, 70 cfg: config.DefaultResource("upjet_resource", nil, nil, nil), 71 fs: func() afero.Afero { 72 return afero.Afero{Fs: afero.NewMemMapFs()} 73 }, 74 }, 75 want: want{ 76 tfstate: `{"version":4,"terraform_version":"","serial":1,"lineage":"","outputs":null,"resources":[{"mode":"managed","type":"","name":"","provider":"provider[\"registry.terraform.io/\"]","instances":[{"schema_version":0,"attributes":{"id":"some-id","name":"some-id","obs":"obsval","param":"paramval"},"private":"cHJpdmF0ZXJhdw=="}]}]}`, 77 }, 78 }, 79 "SuccessWithTimeout": { 80 reason: "Configured timeouts should be reflected tfstate as private meta", 81 args: args{ 82 tr: &fake.Terraformed{ 83 Managed: xpfake.Managed{ 84 ObjectMeta: metav1.ObjectMeta{ 85 Annotations: map[string]string{ 86 resource.AnnotationKeyPrivateRawAttribute: "{}", 87 meta.AnnotationKeyExternalName: "some-id", 88 }, 89 }, 90 }, 91 Parameterizable: fake.Parameterizable{Parameters: map[string]any{ 92 "param": "paramval", 93 }}, 94 Observable: fake.Observable{Observation: map[string]any{ 95 "obs": "obsval", 96 }}, 97 }, 98 cfg: config.DefaultResource("upjet_resource", nil, nil, nil, func(r *config.Resource) { 99 r.OperationTimeouts.Read = 2 * time.Minute 100 }), 101 fs: func() afero.Afero { 102 return afero.Afero{Fs: afero.NewMemMapFs()} 103 }, 104 }, 105 want: want{ 106 tfstate: `{"version":4,"terraform_version":"","serial":1,"lineage":"","outputs":null,"resources":[{"mode":"managed","type":"","name":"","provider":"provider[\"registry.terraform.io/\"]","instances":[{"schema_version":0,"attributes":{"id":"some-id","name":"some-id","obs":"obsval","param":"paramval"},"private":"eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsicmVhZCI6MTIwMDAwMDAwMDAwfX0="}]}]}`, 107 }, 108 }, 109 "SuccessSkipDuringDeletion": { 110 reason: "During an ongoing deletion, tfstate file should not be touched since its emptiness signals success.", 111 args: args{ 112 tr: &fake.Terraformed{ 113 Managed: xpfake.Managed{ 114 ObjectMeta: metav1.ObjectMeta{ 115 DeletionTimestamp: &now, 116 Annotations: map[string]string{ 117 resource.AnnotationKeyPrivateRawAttribute: "privateraw", 118 meta.AnnotationKeyExternalName: "some-id", 119 }, 120 }, 121 }, 122 Parameterizable: fake.Parameterizable{Parameters: map[string]any{ 123 "param": "paramval", 124 }}, 125 Observable: fake.Observable{Observation: map[string]any{ 126 "obs": "obsval", 127 }}, 128 }, 129 cfg: config.DefaultResource("upjet_resource", nil, nil, nil), 130 fs: func() afero.Afero { 131 fss := afero.Afero{Fs: afero.NewMemMapFs()} 132 _ = fss.WriteFile(filepath.Join(dir, "terraform.tfstate"), []byte(empty), 0600) 133 return fss 134 }, 135 }, 136 want: want{ 137 tfstate: empty, 138 }, 139 }, 140 } 141 for name, tc := range cases { 142 t.Run(name, func(t *testing.T) { 143 ctx := context.TODO() 144 files := tc.args.fs() 145 fp, err := NewFileProducer(ctx, nil, dir, tc.args.tr, tc.args.s, tc.args.cfg, WithFileSystem(files)) 146 if err != nil { 147 t.Errorf("cannot initialize a file producer: %s", err.Error()) 148 } 149 err = fp.EnsureTFState(ctx, "some-id") 150 if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { 151 t.Errorf("\n%s\nWriteTFState(...): -want error, +got error:\n%s", tc.reason, diff) 152 } 153 s, _ := files.ReadFile(filepath.Join(dir, "terraform.tfstate")) 154 if diff := cmp.Diff(tc.want.tfstate, string(s)); diff != "" { 155 t.Errorf("\n%s\nWriteTFState(...): -want tfstate, +got tfstate:\n%s", tc.reason, diff) 156 } 157 }) 158 } 159 } 160 161 func TestIsStateEmpty(t *testing.T) { 162 type args struct { 163 fs func() afero.Afero 164 } 165 type want struct { 166 empty bool 167 err error 168 } 169 cases := map[string]struct { 170 reason string 171 args 172 want 173 }{ 174 "FileDoesNotExist": { 175 reason: "If the tfstate file is not there, it should return true.", 176 args: args{ 177 fs: func() afero.Afero { 178 return afero.Afero{Fs: afero.NewMemMapFs()} 179 }, 180 }, 181 want: want{ 182 empty: true, 183 }, 184 }, 185 "NoAttributes": { 186 reason: "If there is no attributes, that means the state is empty.", 187 args: args{ 188 fs: func() afero.Afero { 189 f := afero.Afero{Fs: afero.NewMemMapFs()} 190 s := json.NewStateV4() 191 s.Resources = []json.ResourceStateV4{} 192 d, _ := json.JSParser.Marshal(s) 193 _ = f.WriteFile(filepath.Join(dir, "terraform.tfstate"), d, 0600) 194 return f 195 }, 196 }, 197 want: want{ 198 empty: true, 199 }, 200 }, 201 "NoID": { 202 reason: "If there is no ID in the state, that means state is empty", 203 args: args{ 204 fs: func() afero.Afero { 205 f := afero.Afero{Fs: afero.NewMemMapFs()} 206 s := json.NewStateV4() 207 s.Resources = []json.ResourceStateV4{ 208 { 209 Instances: []json.InstanceObjectStateV4{ 210 { 211 AttributesRaw: []byte(`{}`), 212 }, 213 }, 214 }, 215 } 216 d, _ := json.JSParser.Marshal(s) 217 _ = f.WriteFile(filepath.Join(dir, "terraform.tfstate"), d, 0600) 218 return f 219 }, 220 }, 221 want: want{ 222 empty: true, 223 }, 224 }, 225 "NonStringID": { 226 reason: "If the ID is there but not string, return true.", 227 args: args{ 228 fs: func() afero.Afero { 229 f := afero.Afero{Fs: afero.NewMemMapFs()} 230 s := json.NewStateV4() 231 s.Resources = []json.ResourceStateV4{ 232 { 233 Instances: []json.InstanceObjectStateV4{ 234 { 235 AttributesRaw: []byte(`{"id": 0}`), 236 }, 237 }, 238 }, 239 } 240 d, _ := json.JSParser.Marshal(s) 241 _ = f.WriteFile(filepath.Join(dir, "terraform.tfstate"), d, 0600) 242 return f 243 }, 244 }, 245 want: want{ 246 err: errors.Errorf(errFmtNonString, fmt.Sprint(0)), 247 }, 248 }, 249 "NotEmpty": { 250 reason: "If there is a string ID at minimum, state file is workable", 251 args: args{ 252 fs: func() afero.Afero { 253 f := afero.Afero{Fs: afero.NewMemMapFs()} 254 s := json.NewStateV4() 255 s.Resources = []json.ResourceStateV4{ 256 { 257 Instances: []json.InstanceObjectStateV4{ 258 { 259 AttributesRaw: []byte(`{"id": "someid"}`), 260 }, 261 }, 262 }, 263 } 264 d, _ := json.JSParser.Marshal(s) 265 _ = f.WriteFile(filepath.Join(dir, "terraform.tfstate"), d, 0600) 266 return f 267 }, 268 }, 269 }, 270 } 271 for name, tc := range cases { 272 t.Run(name, func(t *testing.T) { 273 fp, _ := NewFileProducer( 274 context.TODO(), 275 nil, 276 dir, 277 &fake.Terraformed{ 278 Parameterizable: fake.Parameterizable{Parameters: map[string]any{}}, 279 }, 280 Setup{}, 281 config.DefaultResource("upjet_resource", nil, nil, nil), WithFileSystem(tc.args.fs()), 282 ) 283 empty, err := fp.isStateEmpty() 284 if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { 285 t.Errorf("\n%s\nisStateEmpty(...): -want error, +got error:\n%s", tc.reason, diff) 286 } 287 if diff := cmp.Diff(tc.want.empty, empty); diff != "" { 288 t.Errorf("\n%s\nisStateEmpty(...): -want empty, +got empty:\n%s", tc.reason, diff) 289 } 290 }) 291 } 292 } 293 294 func TestWriteMainTF(t *testing.T) { 295 type args struct { 296 tr resource.Terraformed 297 cfg *config.Resource 298 s Setup 299 f *feature.Flags 300 } 301 type want struct { 302 maintf string 303 err error 304 } 305 cases := map[string]struct { 306 reason string 307 args 308 want 309 }{ 310 "TimeoutsConfigured": { 311 reason: "Configured resources should be able to write everything it has into maintf file", 312 args: args{ 313 tr: &fake.Terraformed{ 314 Managed: xpfake.Managed{ 315 ObjectMeta: metav1.ObjectMeta{ 316 Annotations: map[string]string{ 317 resource.AnnotationKeyPrivateRawAttribute: "privateraw", 318 meta.AnnotationKeyExternalName: "some-id", 319 }, 320 }, 321 }, 322 Parameterizable: fake.Parameterizable{Parameters: map[string]any{ 323 "param": "paramval", 324 }}, 325 Observable: fake.Observable{Observation: map[string]any{ 326 "obs": "obsval", 327 }}, 328 }, 329 cfg: config.DefaultResource("upjet_resource", nil, nil, nil, func(r *config.Resource) { 330 r.OperationTimeouts = config.OperationTimeouts{ 331 Read: 30 * time.Second, 332 Update: 2 * time.Minute, 333 } 334 }), 335 s: Setup{ 336 Requirement: ProviderRequirement{ 337 Source: "hashicorp/provider-test", 338 Version: "1.2.3", 339 }, 340 Configuration: nil, 341 }, 342 }, 343 want: want{ 344 maintf: `{"provider":{"provider-test":null},"resource":{"":{"":{"lifecycle":{"prevent_destroy":true},"name":"some-id","param":"paramval","timeouts":{"read":"30s","update":"2m0s"}}}},"terraform":{"required_providers":{"provider-test":{"source":"hashicorp/provider-test","version":"1.2.3"}}}}`, 345 }, 346 }, 347 "Success": { 348 reason: "Standard resources should be able to write everything it has into maintf file", 349 args: args{ 350 tr: &fake.Terraformed{ 351 Managed: xpfake.Managed{ 352 ObjectMeta: metav1.ObjectMeta{ 353 Annotations: map[string]string{ 354 resource.AnnotationKeyPrivateRawAttribute: "privateraw", 355 meta.AnnotationKeyExternalName: "some-id", 356 }, 357 }, 358 }, 359 Parameterizable: fake.Parameterizable{Parameters: map[string]any{ 360 "param": "paramval", 361 }}, 362 Observable: fake.Observable{Observation: map[string]any{ 363 "obs": "obsval", 364 }}, 365 }, 366 cfg: config.DefaultResource("upjet_resource", nil, nil, nil), 367 s: Setup{ 368 Requirement: ProviderRequirement{ 369 Source: "hashicorp/provider-test", 370 Version: "1.2.3", 371 }, 372 Configuration: nil, 373 }, 374 }, 375 want: want{ 376 maintf: `{"provider":{"provider-test":null},"resource":{"":{"":{"lifecycle":{"prevent_destroy":true},"name":"some-id","param":"paramval"}}},"terraform":{"required_providers":{"provider-test":{"source":"hashicorp/provider-test","version":"1.2.3"}}}}`, 377 }, 378 }, 379 "Custom Source": { 380 reason: "Custom source like my-company/namespace/provider-test resources should be able to write everything it has into maintf file", 381 args: args{ 382 tr: &fake.Terraformed{ 383 Managed: xpfake.Managed{ 384 ObjectMeta: metav1.ObjectMeta{ 385 Annotations: map[string]string{ 386 resource.AnnotationKeyPrivateRawAttribute: "privateraw", 387 meta.AnnotationKeyExternalName: "some-id", 388 }, 389 }, 390 }, 391 Parameterizable: fake.Parameterizable{Parameters: map[string]any{ 392 "param": "paramval", 393 }}, 394 Observable: fake.Observable{Observation: map[string]any{ 395 "obs": "obsval", 396 }}, 397 }, 398 cfg: config.DefaultResource("upjet_resource", nil, nil, nil), 399 s: Setup{ 400 Requirement: ProviderRequirement{ 401 Source: "my-company/namespace/provider-test", 402 Version: "1.2.3", 403 }, 404 Configuration: nil, 405 }, 406 }, 407 want: want{ 408 maintf: `{"provider":{"provider-test":null},"resource":{"":{"":{"lifecycle":{"prevent_destroy":true},"name":"some-id","param":"paramval"}}},"terraform":{"required_providers":{"provider-test":{"source":"my-company/namespace/provider-test","version":"1.2.3"}}}}`, 409 }, 410 }, 411 "SuccessManagementPolicies": { 412 reason: "Management policies enabled with ignore changes resources and merging initProvider should be able to write everything it has into maintf file", 413 args: args{ 414 tr: &fake.Terraformed{ 415 Managed: xpfake.Managed{ 416 ObjectMeta: metav1.ObjectMeta{ 417 Annotations: map[string]string{ 418 resource.AnnotationKeyPrivateRawAttribute: "privateraw", 419 meta.AnnotationKeyExternalName: "some-id", 420 }, 421 }, 422 }, 423 Parameterizable: fake.Parameterizable{Parameters: map[string]any{ 424 "param": "paramval", 425 "array": []any{ 426 map[string]any{ 427 "other": "val1", 428 }, 429 }, 430 "map": map[string]any{ 431 "mapKey": "val2", 432 }, 433 }, 434 InitParameters: map[string]any{ 435 "param": "should-not-overwrite", 436 "ignored": "ignoredval", 437 "array": []any{ 438 map[string]any{ 439 "key": "val3", 440 "other": "should-not-overwrite", 441 }, 442 }, 443 "map": map[string]any{ 444 "mapKey": "should-not-overwrite", 445 "ignoredKey": "should-be-ignored", 446 }, 447 }}, 448 Observable: fake.Observable{Observation: map[string]any{ 449 "obs": "obsval", 450 }}, 451 }, 452 cfg: config.DefaultResource("upjet_resource", nil, nil, nil), 453 s: Setup{ 454 Requirement: ProviderRequirement{ 455 Source: "hashicorp/provider-test", 456 Version: "1.2.3", 457 }, 458 Configuration: nil, 459 }, 460 f: func() *feature.Flags { 461 f := &feature.Flags{} 462 f.Enable(feature.EnableBetaManagementPolicies) 463 return f 464 }(), 465 }, 466 want: want{ 467 maintf: `{"provider":{"provider-test":null},"resource":{"":{"":{"array":[{"key":"val3","other":"val1"}],"ignored":"ignoredval","lifecycle":{"ignore_changes":["array[0].key","ignored","map[\"ignoredKey\"]"],"prevent_destroy":true},"map":{"ignoredKey":"should-be-ignored","mapKey":"val2"},"name":"some-id","param":"paramval"}}},"terraform":{"required_providers":{"provider-test":{"source":"hashicorp/provider-test","version":"1.2.3"}}}}`, 468 }, 469 }, 470 } 471 for name, tc := range cases { 472 t.Run(name, func(t *testing.T) { 473 fs := afero.NewMemMapFs() 474 fp, err := NewFileProducer(context.TODO(), nil, dir, tc.args.tr, tc.args.s, tc.args.cfg, WithFileSystem(fs), WithFileProducerFeatures(tc.args.f)) 475 if err != nil { 476 t.Errorf("cannot initialize a file producer: %s", err.Error()) 477 } 478 _, err = fp.WriteMainTF() 479 if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { 480 t.Errorf("\n%s\nWriteMainTF(...): -want error, +got error:\n%s", tc.reason, diff) 481 } 482 s, _ := afero.Afero{Fs: fs}.ReadFile(filepath.Join(dir, "main.tf.json")) 483 var res map[string]any 484 var wantJson map[string]any 485 if err = json.JSParser.Unmarshal(s, &res); err != nil { 486 t.Errorf("cannot unmarshal main.tf.json: %v", err) 487 } 488 if err = json.JSParser.Unmarshal([]byte(tc.want.maintf), &wantJson); err != nil { 489 t.Errorf("cannot unmarshal want main.tf.json: %v", err) 490 } 491 if diff := cmp.Diff(res, wantJson, test.EquateConditions()); diff != "" { 492 t.Errorf("\n%s\nWriteMainTF(...): -want error, +got error:\n%s", tc.reason, diff) 493 } 494 }) 495 } 496 }