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  }